Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add profile page #95

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
NEXT_PUBLIC_PRIVY_APP_ID=""
NEXT_PUBLIC_PRIVY_APP_SECRET=""
LEVEL_UP_BLOB_READ_WRITE_TOKEN=""

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ yarn-debug.log*
yarn-error.log*

# local env files
.env
.env*.local
.vscode

Expand Down
26,023 changes: 12,172 additions & 13,851 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,17 @@
"@mui/material": "^5.13.5",
"@mui/material-nextjs": "^5.15.5",
"@next/third-parties": "^14.2.5",
"@rainbow-me/rainbowkit": "^1.2.0",
"@privy-io/react-auth": "^1.99.1",
"@privy-io/server-auth": "^1.17.1",
"@privy-io/wagmi": "^0.2.12",
"@svgr/webpack": "^8.1.0",
"@tanstack/react-query": "^5",
"@thi.ng/checks": "^3.6.16",
"@thi.ng/compose": "^3.0.17",
"@types/mdx": "^2.0.12",
"@vercel/analytics": "^1.3.1",
"@vercel/blob": "^0.27.0",
"@vercel/kv": "^0.2.4",
"clsx": "^2.1.1",
"dayjs": "^1.11.5",
"echarts": "^5.5.1",
Expand All @@ -54,8 +61,9 @@
"remark-math": "^5.1.1",
"swr": "^2.1.3",
"tss-react": "^4.4.1",
"viem": "^1.10.1",
"wagmi": "^1.4.12"
"viem": "^2.16.0",
"wagmi": "^2.10.4",
"zustand": "^5.0.2"
},
"devDependencies": {
"@emotion/babel-plugin": "^11.11.0",
Expand Down
57 changes: 57 additions & 0 deletions src/app/api/challenge/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { getUserFromToken, privy } from "@/app/api/utils/auth";

export async function POST(req) {
try {
const { challengeId } = await req.json();
if (!challengeId || typeof challengeId !== "string") {
return new Response(
JSON.stringify({ error: "Invalid or missing challengeId" }),
{ status: 400 },
);
}

const { customMetadata, id: uid } = await getUserFromToken(req);
if (!uid) {
return new Response(
JSON.stringify({ error: "Unauthorized: Invalid token" }),
{ status: 401 },
);
}

let challengesData = {};
try {
challengesData = JSON.parse(
(customMetadata?.challenges as string) || "{}",
);
} catch (e) {
console.error("Failed to parse challenges:", e);
return new Response(
JSON.stringify({ error: "Invalid challenges metadata format" }),
{ status: 500 },
);
}

challengesData[challengeId] = true;

try {
await privy.setCustomMetadata(uid, {
...customMetadata,
challenges: JSON.stringify(challengesData),
});
} catch (e) {
console.error("Failed to set custom metadata:", e);
return new Response(
JSON.stringify({ error: "Failed to update custom metadata" }),
{ status: 500 },
);
}

return new Response(JSON.stringify({ success: true }), { status: 200 });
} catch (err) {
console.error("Unexpected error:", err);
return new Response(
JSON.stringify({ error: "Internal server error", details: err.message }),
{ status: 500 },
);
}
}
58 changes: 58 additions & 0 deletions src/app/api/lesson/progress/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { getUserFromToken, privy } from "@/app/api/utils/auth";

export async function POST(req) {
try {
const { lessonId, step } = await req.json();
if (!lessonId || typeof lessonId !== "string" || step == null) {
return new Response(
JSON.stringify({ error: "Invalid or missing lessonId or step" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}

const { customMetadata, id: uid } = await getUserFromToken(req);
if (!uid) {
return new Response(
JSON.stringify({ error: "Unauthorized: Invalid token" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}

let lessonsData = {};
try {
lessonsData = JSON.parse((customMetadata?.lessons as string) || "{}");
} catch (e) {
console.error("Failed to parse lessons metadata:", e);
return new Response(
JSON.stringify({ error: "Invalid lessons metadata format" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}

lessonsData[lessonId] = step;

try {
await privy.setCustomMetadata(uid, {
...customMetadata,
lessons: JSON.stringify(lessonsData),
});
} catch (e) {
console.error("Failed to set custom metadata:", e);
return new Response(
JSON.stringify({ error: "Failed to update custom metadata" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}

return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
console.error("Unexpected error in POST:", err);
return new Response(
JSON.stringify({ error: "Internal server error", details: err.message }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
}
113 changes: 113 additions & 0 deletions src/app/api/user/profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { isString } from "@thi.ng/checks";
import { NextResponse } from "next/server";
import { put } from "@vercel/blob";
import { getUserFromToken, privy } from "@/app/api/utils/auth";

const MAX_IMAGE_SIZE = 2 * 1024 * 1024; // 2MB

/**
* Validates the username.
* @param {string} username
*/
function validateUsername(username) {
if (!isString(username) || username.trim().length === 0) {
throw new Error("Invalid username: must be a non-empty string.");
}
}

/**
* Validates the uploaded image file.
* @param {File} image
*/
function validateImage(image) {
const allowedTypePattern = /^image\/(png|jpeg|webp)$/;

if (!allowedTypePattern.test(image.type)) {
throw new Error(`Invalid image type. Allowed types are: PNG, JPEG, WEBP.`);
}
if (image.size > MAX_IMAGE_SIZE) {
throw new Error(`Image size exceeds the limit of ${MAX_IMAGE_SIZE} bytes.`);
}
}

/**
* Uploads the avatar image to the server.
* @param {File} image
* @returns {Promise<string>} The URL of the uploaded image.
*/
async function uploadImage(image) {
const imageExt = image.type.split("/").pop();
const response = await put(`/profile/avatar.${imageExt}`, image, {
contentType: image.type,
access: "public",
addRandomSuffix: true,
});

if (!response.url) {
throw new Error("Failed to upload avatar.");
}

return response.url;
}

/**
* Handles the POST request to update user profile.
* @param {Request} req
*/
export async function POST(req) {
try {
const { customMetadata, id: uid } = await getUserFromToken(req);
if (!uid) {
return new Response("Unauthorized: User not authenticated.", {
status: 401,
});
}

let form;
try {
form = await req.formData();
} catch (err) {
console.error("Failed to parse request form:", err);
return new Response("Failed to parse request form.", { status: 400 });
}

const username = form.get("username");
const image = form.get("avatar");
const avatarUrlInput = form.get("avatarUrl");

validateUsername(username);

let avatarUrl: null | string = null;
if (image) {
validateImage(image);
avatarUrl = await uploadImage(image);
} else if (avatarUrlInput) {
if (!isString(avatarUrlInput) || !avatarUrlInput.startsWith("http")) {
throw new Error("Invalid avatar URL: must start with http/https.");
}
avatarUrl = avatarUrlInput;
}

const userProfileData = {
username,
...(avatarUrl && { avatar: avatarUrl }),
};

await privy.setCustomMetadata(uid, {
...customMetadata,
...userProfileData,
});

return NextResponse.json({
success: true,
...customMetadata,
...userProfileData,
});
} catch (error) {
console.error("Error updating profile:", error.message);
return new Response(
JSON.stringify({ success: false, error: error.message }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
}
23 changes: 23 additions & 0 deletions src/app/api/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { PrivyClient } from "@privy-io/server-auth";

const privy = new PrivyClient(
process.env.NEXT_PUBLIC_PRIVY_APP_ID as string,
process.env.NEXT_PUBLIC_PRIVY_APP_SECRET as string,
);

async function getUserFromToken(req) {
try {
const authToken = req.cookies.get("privy-token")?.value;
if (!authToken) {
throw new Error("No ID token found in cookies");
}
const verifiedClaims = await privy.verifyAuthToken(authToken);
const user = await privy.getUserById(verifiedClaims.userId);
return user;
} catch (error) {
console.error("Error validating ID token:", error);
throw new Error("Invalid or expired token");
}
}

export { getUserFromToken, privy };
11 changes: 9 additions & 2 deletions src/app/challenges/List/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import Card from "@/components/Card";

import PlainSelect from "@/components/PlainSelect";
import NoData from "@/components/NoData";
import useProgressStore from "@/stores/processStore";

const ChallengeList = (props) => {
const { challenges } = useProgressStore();
const { data } = props;
const trigger = useScrollTrigger();
const [searchParams, setSearchParams] = useState({
Expand All @@ -42,8 +44,9 @@ const ChallengeList = (props) => {
.map((item) => ({
...item,
labels: [...item.labels, `Level ${item.level}`],
isCompleted: !!challenges[item.id],
}));
}, [searchParams, data]);
}, [searchParams, data, challenges]);

const handleChangeCategory = (e) => {
setSearchParams((pre) => ({
Expand Down Expand Up @@ -99,7 +102,11 @@ const ChallengeList = (props) => {
}}
>
{filteredData.map((item) => (
<Card content={item} key={item.name}></Card>
<Card
content={item}
key={item.name}
isCompleted={item.isCompleted}
></Card>
))}
</Box>
) : (
Expand Down
Loading