From e8fde1121cdb07d149cd0a4eb19543105dff9082 Mon Sep 17 00:00:00 2001 From: liyunze <50455574+liyunze-coding@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:11:14 +0800 Subject: [PATCH] added account page, profile picture --- src/lib/components/Avatar.svelte | 52 +++++ src/lib/components/CommentNode.svelte | 9 +- src/lib/components/Navbar.svelte | 79 ++++---- src/lib/server/api.ts | 129 +++++++++++- src/lib/server/db.ts | 4 + src/lib/stores/auth.ts | 5 + src/lib/types.ts | 2 + src/routes/account/+page.svelte | 261 +++++++++++++++++++++++++ src/routes/notes/[id]/+page.svelte | 5 +- src/routes/questions/[id]/+page.svelte | 5 +- src/routes/users/[id]/+page.svelte | 160 +++++++++++++++ 11 files changed, 661 insertions(+), 50 deletions(-) create mode 100644 src/lib/components/Avatar.svelte create mode 100644 src/routes/account/+page.svelte create mode 100644 src/routes/users/[id]/+page.svelte diff --git a/src/lib/components/Avatar.svelte b/src/lib/components/Avatar.svelte new file mode 100644 index 0000000..b4d5309 --- /dev/null +++ b/src/lib/components/Avatar.svelte @@ -0,0 +1,52 @@ + + + diff --git a/src/lib/components/CommentNode.svelte b/src/lib/components/CommentNode.svelte index 92bda2a..a543cae 100644 --- a/src/lib/components/CommentNode.svelte +++ b/src/lib/components/CommentNode.svelte @@ -4,6 +4,7 @@ import { get } from "svelte/store"; import { timeAgo } from "$lib/time"; import CommentNode from "./CommentNode.svelte"; + import Avatar from "./Avatar.svelte"; import type { CommentDoc } from "$lib/types"; interface CommentTreeNode { @@ -133,9 +134,15 @@
+ + +

- {comment.authorName} · {timeAgo(comment.createdAt)} + {comment.authorName} + · {timeAgo(comment.createdAt)} {#if edited} · Edited {/if} diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte index 7d58d28..0e45b5c 100644 --- a/src/lib/components/Navbar.svelte +++ b/src/lib/components/Navbar.svelte @@ -1,6 +1,8 @@

@@ -99,18 +106,14 @@ {#if admin} Admin {/if} - {#if auth} - + + {:else} Sign in {/if} @@ -162,21 +165,28 @@ - + + + + +
{#if mobileMenuOpen} @@ -204,20 +214,7 @@ >Admin {/if} - {#if auth} - - {:else} + {#if !auth} { const user = db .prepare( - "SELECT id AS _id, email, name, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?", + "SELECT id AS _id, email, name, avatarUrl, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?", ) .get(token) as Record | undefined; if (!user) throw new Error("Not authenticated"); @@ -245,12 +245,126 @@ function usersGetByToken(db: Db, args: { token: string }) { return ( db .prepare( - "SELECT id AS _id, email, name, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?", + "SELECT id AS _id, email, name, avatarUrl, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?", ) .get(args.token) ?? null ); } +function usersUpdateProfile( + db: Db, + args: { token: string; name: string; avatarUrl?: string | null }, +) { + const user = requireAuth(db, args.token); + const name = (args.name ?? "").trim(); + if (name.length < 2 || name.length > 50) { + throw new Error("Display name must be between 2 and 50 characters"); + } + + const avatarUrl = args.avatarUrl?.trim() || null; + if (avatarUrl && !/^\/uploads\/[a-zA-Z0-9-]+\.(png|jpe?g|gif|webp)$/.test(avatarUrl)) { + throw new Error("Invalid profile picture"); + } + + db.exec("BEGIN"); + try { + db.prepare("UPDATE users SET name = ?, avatarUrl = ? WHERE id = ?").run( + name, + avatarUrl, + user._id, + ); + db.prepare("UPDATE notes SET authorName = ? WHERE authorId = ?").run(name, user._id); + db.prepare("UPDATE questions SET authorName = ? WHERE authorId = ?").run(name, user._id); + db.prepare("UPDATE comments SET authorName = ? WHERE authorId = ?").run(name, user._id); + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } + + return usersGetByToken(db, { token: args.token }); +} + +function usersChangePassword( + db: Db, + args: { token: string; currentPassword: string; newPassword: string }, +) { + const user = requireAuth(db, args.token); + const credentials = db.prepare("SELECT passwordHash FROM users WHERE id = ?").get(user._id) as + | { passwordHash: string | null } + | undefined; + if ( + !credentials?.passwordHash || + !verifyPassword(args.currentPassword ?? "", credentials.passwordHash) + ) { + throw new Error("Current password is incorrect"); + } + + const newPassword = validatePassword(args.newPassword); + if (verifyPassword(newPassword, credentials.passwordHash)) { + throw new Error("New password must be different from your current password"); + } + db.prepare("UPDATE users SET passwordHash = ? WHERE id = ?").run( + hashPassword(newPassword), + user._id, + ); + return { ok: true }; +} + +function usersGetPublicProfile(db: Db, args: { id: string }) { + const profile = db + .prepare( + `SELECT + u.id AS _id, + u.name, + u.avatarUrl, + u.createdAt AS _creationTime, + (SELECT COUNT(*) FROM notes n WHERE n.authorId = u.id) AS noteCount, + (SELECT COUNT(*) FROM questions q WHERE q.authorId = u.id) AS questionCount, + (SELECT COUNT(*) FROM comments c WHERE c.authorId = u.id) AS commentCount + FROM users u + WHERE u.id = ?`, + ) + .get(args.id) as Record | undefined; + if (!profile) return null; + + const posts = db + .prepare( + `SELECT id AS _id, 'note' AS type, title, createdAt, voteCount + FROM notes WHERE authorId = ? + UNION ALL + SELECT id AS _id, 'question' AS type, title, createdAt, voteCount + FROM questions WHERE authorId = ? + ORDER BY createdAt DESC + LIMIT 50`, + ) + .all(args.id, args.id); + const comments = db + .prepare( + `SELECT + c.id AS _id, + c.content, + c.createdAt, + CASE WHEN c.parentId IS NOT NULL THEN 'note' ELSE 'question' END AS targetType, + COALESCE(c.parentId, c.questionId) AS targetId, + COALESCE(n.title, q.title) AS targetTitle + FROM comments c + LEFT JOIN notes n ON n.id = c.parentId + LEFT JOIN questions q ON q.id = c.questionId + WHERE c.authorId = ? + ORDER BY c.createdAt DESC + LIMIT 50`, + ) + .all(args.id); + + return { + ...profile, + totalContributions: profile.noteCount + profile.questionCount + profile.commentCount, + posts, + comments, + }; +} + // ---- topics ---- function topicsGetBySlug(db: Db, args: { slug: string }) { @@ -609,7 +723,7 @@ function questionsUpdate( // ---- comments ---- const COMMENT_COLUMNS = - "id AS _id, content, authorId, authorName, parentId, questionId, parentCommentId, createdAt, updatedAt"; + "c.id AS _id, c.content, c.authorId, COALESCE(u.name, c.authorName) AS authorName, u.avatarUrl, c.parentId, c.questionId, c.parentCommentId, c.createdAt, c.updatedAt"; function commentsCreateOnNote( db: Db, @@ -664,7 +778,7 @@ function commentsCreateOnQuestion( function commentsListByNote(db: Db, args: { noteId: string }) { return db .prepare( - `SELECT ${COMMENT_COLUMNS} FROM comments WHERE parentId = ? ORDER BY createdAt ASC`, + `SELECT ${COMMENT_COLUMNS} FROM comments c LEFT JOIN users u ON u.id = c.authorId WHERE c.parentId = ? ORDER BY c.createdAt ASC`, ) .all(args.noteId); } @@ -672,7 +786,7 @@ function commentsListByNote(db: Db, args: { noteId: string }) { function commentsListByQuestion(db: Db, args: { questionId: string }) { return db .prepare( - `SELECT ${COMMENT_COLUMNS} FROM comments WHERE questionId = ? ORDER BY createdAt ASC`, + `SELECT ${COMMENT_COLUMNS} FROM comments c LEFT JOIN users u ON u.id = c.authorId WHERE c.questionId = ? ORDER BY c.createdAt ASC`, ) .all(args.questionId); } @@ -834,7 +948,7 @@ function adminGetState(db: Db, args: { token?: string }) { if (args.token) { const user = db .prepare( - "SELECT id AS _id, email, name, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?", + "SELECT id AS _id, email, name, avatarUrl, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?", ) .get(args.token) as Record | undefined; if (user) { @@ -1161,6 +1275,9 @@ const handlers: Record = { "auth:forgotPassword": authForgotPassword, "auth:resetPassword": authResetPassword, "users:getByToken": usersGetByToken, + "users:updateProfile": usersUpdateProfile, + "users:changePassword": usersChangePassword, + "users:getPublicProfile": usersGetPublicProfile, "topics:getBySlug": topicsGetBySlug, "topics:getAll": topicsGetAll, "units:getByCode": unitsGetByCode, diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 097fa2b..987a669 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -16,6 +16,7 @@ function createSchema(database: DatabaseSync) { name TEXT NOT NULL, sessionToken TEXT, passwordHash TEXT, + avatarUrl TEXT, role TEXT NOT NULL DEFAULT 'user', createdAt INTEGER NOT NULL ); @@ -231,6 +232,9 @@ function migrate(database: DatabaseSync) { if (!columns.some((c) => c.name === "role")) { database.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"); } + if (!columns.some((c) => c.name === "avatarUrl")) { + database.exec("ALTER TABLE users ADD COLUMN avatarUrl TEXT"); + } const unitColumns = database.prepare("PRAGMA table_info(units)").all() as { name: string }[]; if (!unitColumns.some((c) => c.name === "code2")) { diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 552b710..39919a4 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -90,6 +90,11 @@ export function getToken(): string | null { } } +export function setCurrentUser(user: UserDoc) { + currentUser.set(user); + isAuthenticated.set(true); +} + export function logout() { localStorage.removeItem(STORAGE_KEY); currentUser.set(null); diff --git a/src/lib/types.ts b/src/lib/types.ts index aeae741..af0ab8c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -8,6 +8,7 @@ export type Doc = { export type UserDoc = Doc<"users"> & { email: string; name: string; + avatarUrl?: string; sessionToken?: string; role: "user" | "admin"; }; @@ -60,6 +61,7 @@ export type CommentDoc = Doc<"comments"> & { content: string; authorId: Id<"users">; authorName: string; + avatarUrl?: string; parentId?: Id<"notes">; questionId?: Id<"questions">; parentCommentId?: Id<"comments">; diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte new file mode 100644 index 0000000..8117244 --- /dev/null +++ b/src/routes/account/+page.svelte @@ -0,0 +1,261 @@ + + + + Account — Notebook + + +
+ {#if loading} +

Loading

+ {:else if user} +
+ +
+
+
+

Profile picture

+
+ +
+ + {#if avatarUrl || previewUrl} + + {/if} +
+
+

PNG, JPG, GIF or WebP. Maximum 10 MB.

+
+ +
+ + +
+ +
+ + +

+ Your verified email cannot be changed here. +

+
+ + {#if profileError}

{profileError}

{/if} + {#if profileMessage}

{profileMessage}

{/if} + +
+ +
+
+

Password

+

Change password

+
+
+ + +
+
+ + +
+
+ + +
+ {#if passwordError}

{passwordError}

{/if} + {#if passwordMessage}

{passwordMessage}

{/if} + +
+
+ {/if} +
diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte index 2dafa03..fa8e189 100644 --- a/src/routes/notes/[id]/+page.svelte +++ b/src/routes/notes/[id]/+page.svelte @@ -186,7 +186,10 @@ {/if} {#if !editMode}

- {note.authorName} · {timeAgo(note.createdAt)} + {note.authorName} + · {timeAgo(note.createdAt)} {#if isAuthor} · + +

+ +
+ {#if tab === "posts"} + {#if profile.posts.length === 0} +

No posts yet.

+ {:else} + {#each profile.posts as post} +
+

{post.type}

+ + {post.title} + +

+ {timeAgo(post.createdAt)} · {post.voteCount} vote{post.voteCount === + 1 + ? "" + : "s"} +

+
+ {/each} + {/if} + {:else if profile.comments.length === 0} +

No comments yet.

+ {:else} + {#each profile.comments as comment} +
+

+ On + + {comment.targetTitle ?? `a ${comment.targetType}`} + +

+

+ {previewMarkdown(comment.content)} +

+

{timeAgo(comment.createdAt)}

+
+ {/each} + {/if} +
+ {:else} +

Profile not found

+

This user does not exist.

+ {/if} +