Merge pull request #12 from dsec-hub/feature/account-page

added account page, profile picture
This commit is contained in:
RythonDev 2026-09-02 23:13:42 +08:00 committed by GitHub
commit a32ece672a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 661 additions and 50 deletions

View file

@ -0,0 +1,52 @@
<script lang="ts">
let {
src,
name,
size = "md",
}: {
src?: string | null;
name: string;
size?: "sm" | "md" | "lg" | "xl";
} = $props();
const dimensions = $derived(
size === "sm"
? "h-7 w-7"
: size === "lg"
? "h-16 w-16"
: size === "xl"
? "h-24 w-24"
: "h-10 w-10",
);
const iconSize = $derived(
size === "sm"
? "h-4 w-4"
: size === "lg"
? "h-8 w-8"
: size === "xl"
? "h-12 w-12"
: "h-5 w-5",
);
</script>
<div
class="{dimensions} border-rule bg-rule text-muted inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border"
role="img"
aria-label={`${name}'s profile picture`}
>
{#if src}
<img {src} alt="" class="h-full w-full object-cover" />
{:else}
<svg
class={iconSize}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
{/if}
</div>

View file

@ -4,6 +4,7 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { timeAgo } from "$lib/time"; import { timeAgo } from "$lib/time";
import CommentNode from "./CommentNode.svelte"; import CommentNode from "./CommentNode.svelte";
import Avatar from "./Avatar.svelte";
import type { CommentDoc } from "$lib/types"; import type { CommentDoc } from "$lib/types";
interface CommentTreeNode { interface CommentTreeNode {
@ -133,9 +134,15 @@
<div class="py-3"> <div class="py-3">
<div class="flex gap-3"> <div class="flex gap-3">
<a href={`/users/${comment.authorId}`} aria-label={`View ${comment.authorName}'s profile`}>
<Avatar src={comment.avatarUrl} name={comment.authorName} />
</a>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="kicker"> <p class="kicker">
{comment.authorName} · <span class="text-faint">{timeAgo(comment.createdAt)}</span> <a href={`/users/${comment.authorId}`} class="hover:text-primary"
>{comment.authorName}</a
>
· <span class="text-faint">{timeAgo(comment.createdAt)}</span>
{#if edited} {#if edited}
· Edited · Edited
{/if} {/if}

View file

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { isAuthenticated, initAuth, logout, currentUser } from "$lib/stores/auth"; import { isAuthenticated, initAuth, currentUser } from "$lib/stores/auth";
import { theme, toggleTheme } from "$lib/stores/theme"; import { theme, toggleTheme } from "$lib/stores/theme";
import Avatar from "./Avatar.svelte";
import type { UserDoc } from "$lib/types";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@ -9,6 +11,7 @@
let resourcesOpen = $state(false); let resourcesOpen = $state(false);
let auth = $state(false); let auth = $state(false);
let admin = $state(false); let admin = $state(false);
let navUser: UserDoc | null = $state(null);
let searchQuery = $state(""); let searchQuery = $state("");
let isDark = $state(false); let isDark = $state(false);
@ -24,7 +27,10 @@
onMount(() => { onMount(() => {
initAuth(); initAuth();
const unsubAuth = isAuthenticated.subscribe((v) => (auth = v)); const unsubAuth = isAuthenticated.subscribe((v) => (auth = v));
const unsubUser = currentUser.subscribe((u) => (admin = u?.role === "admin")); const unsubUser = currentUser.subscribe((u) => {
navUser = u;
admin = u?.role === "admin";
});
const unsubTheme = theme.subscribe((t) => (isDark = t === "dark")); const unsubTheme = theme.subscribe((t) => (isDark = t === "dark"));
return () => { return () => {
unsubAuth(); unsubAuth();
@ -39,6 +45,7 @@
const onQuestions = $derived(path === "/questions" || path.startsWith("/questions/")); const onQuestions = $derived(path === "/questions" || path.startsWith("/questions/"));
const onResources = $derived(onUnits || onNotes || onQuestions); const onResources = $derived(onUnits || onNotes || onQuestions);
const onAdmin = $derived(path === "/admin" || path.startsWith("/admin/")); const onAdmin = $derived(path === "/admin" || path.startsWith("/admin/"));
const onAccount = $derived(path === "/account");
</script> </script>
<header class="border-rule bg-surface border-b"> <header class="border-rule bg-surface border-b">
@ -99,18 +106,14 @@
{#if admin} {#if admin}
<a href="/admin" class="nav-link {onAdmin ? 'nav-link-active' : ''}">Admin</a> <a href="/admin" class="nav-link {onAdmin ? 'nav-link-active' : ''}">Admin</a>
{/if} {/if}
{#if auth} {#if auth && navUser}
<button <a
type="button" href="/account"
class="nav-link" class="hover:ring-primary rounded-full ring-offset-2 ring-offset-(--color-surface) transition hover:ring-2"
onclick={() => { aria-label="Open account"
if (!confirm("Sign out?")) return;
logout();
goto("/");
}}
> >
Sign out <Avatar src={navUser.avatarUrl} name={navUser.name} size="sm" />
</button> </a>
{:else} {:else}
<a href="/auth/login" class="nav-link">Sign in</a> <a href="/auth/login" class="nav-link">Sign in</a>
{/if} {/if}
@ -162,21 +165,28 @@
</form> </form>
</nav> </nav>
<button <div class="flex items-center gap-3 md:hidden">
class="text-muted hover:text-ink p-1 md:hidden" {#if auth && navUser}
aria-label="Open menu" <a href="/account" aria-label="Open account">
onclick={() => (mobileMenuOpen = !mobileMenuOpen)} <Avatar src={navUser.avatarUrl} name={navUser.name} size="sm" />
> </a>
<svg {/if}
class="h-5 w-5" <button
fill="none" class="text-muted hover:text-ink p-1"
viewBox="0 0 24 24" aria-label="Open menu"
stroke="currentColor" onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
stroke-width="1.5"
> >
<path stroke-linecap="square" d="M4 7h16M4 12h16M4 17h16" /> <svg
</svg> class="h-5 w-5"
</button> fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="square" d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
</div>
</div> </div>
{#if mobileMenuOpen} {#if mobileMenuOpen}
@ -204,20 +214,7 @@
>Admin</a >Admin</a
> >
{/if} {/if}
{#if auth} {#if !auth}
<button
type="button"
class="nav-link block"
onclick={() => {
if (!confirm("Sign out?")) return;
logout();
goto("/");
mobileMenuOpen = false;
}}
>
Sign out
</button>
{:else}
<a <a
href="/auth/login" href="/auth/login"
class="nav-link block" class="nav-link block"

View file

@ -71,7 +71,7 @@ function normalizeEmail(email: string): string {
function requireAuth(db: Db, token: string): Record<string, any> { function requireAuth(db: Db, token: string): Record<string, any> {
const user = db const user = db
.prepare( .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<string, any> | undefined; .get(token) as Record<string, any> | undefined;
if (!user) throw new Error("Not authenticated"); if (!user) throw new Error("Not authenticated");
@ -245,12 +245,126 @@ function usersGetByToken(db: Db, args: { token: string }) {
return ( return (
db db
.prepare( .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 .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<string, any> | 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 ---- // ---- topics ----
function topicsGetBySlug(db: Db, args: { slug: string }) { function topicsGetBySlug(db: Db, args: { slug: string }) {
@ -609,7 +723,7 @@ function questionsUpdate(
// ---- comments ---- // ---- comments ----
const COMMENT_COLUMNS = 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( function commentsCreateOnNote(
db: Db, db: Db,
@ -664,7 +778,7 @@ function commentsCreateOnQuestion(
function commentsListByNote(db: Db, args: { noteId: string }) { function commentsListByNote(db: Db, args: { noteId: string }) {
return db return db
.prepare( .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); .all(args.noteId);
} }
@ -672,7 +786,7 @@ function commentsListByNote(db: Db, args: { noteId: string }) {
function commentsListByQuestion(db: Db, args: { questionId: string }) { function commentsListByQuestion(db: Db, args: { questionId: string }) {
return db return db
.prepare( .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); .all(args.questionId);
} }
@ -834,7 +948,7 @@ function adminGetState(db: Db, args: { token?: string }) {
if (args.token) { if (args.token) {
const user = db const user = db
.prepare( .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<string, any> | undefined; .get(args.token) as Record<string, any> | undefined;
if (user) { if (user) {
@ -1161,6 +1275,9 @@ const handlers: Record<string, Handler> = {
"auth:forgotPassword": authForgotPassword, "auth:forgotPassword": authForgotPassword,
"auth:resetPassword": authResetPassword, "auth:resetPassword": authResetPassword,
"users:getByToken": usersGetByToken, "users:getByToken": usersGetByToken,
"users:updateProfile": usersUpdateProfile,
"users:changePassword": usersChangePassword,
"users:getPublicProfile": usersGetPublicProfile,
"topics:getBySlug": topicsGetBySlug, "topics:getBySlug": topicsGetBySlug,
"topics:getAll": topicsGetAll, "topics:getAll": topicsGetAll,
"units:getByCode": unitsGetByCode, "units:getByCode": unitsGetByCode,

View file

@ -16,6 +16,7 @@ function createSchema(database: DatabaseSync) {
name TEXT NOT NULL, name TEXT NOT NULL,
sessionToken TEXT, sessionToken TEXT,
passwordHash TEXT, passwordHash TEXT,
avatarUrl TEXT,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
createdAt INTEGER NOT NULL createdAt INTEGER NOT NULL
); );
@ -231,6 +232,9 @@ function migrate(database: DatabaseSync) {
if (!columns.some((c) => c.name === "role")) { if (!columns.some((c) => c.name === "role")) {
database.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"); 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 }[]; const unitColumns = database.prepare("PRAGMA table_info(units)").all() as { name: string }[];
if (!unitColumns.some((c) => c.name === "code2")) { if (!unitColumns.some((c) => c.name === "code2")) {

View file

@ -90,6 +90,11 @@ export function getToken(): string | null {
} }
} }
export function setCurrentUser(user: UserDoc) {
currentUser.set(user);
isAuthenticated.set(true);
}
export function logout() { export function logout() {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
currentUser.set(null); currentUser.set(null);

View file

@ -8,6 +8,7 @@ export type Doc<T extends string> = {
export type UserDoc = Doc<"users"> & { export type UserDoc = Doc<"users"> & {
email: string; email: string;
name: string; name: string;
avatarUrl?: string;
sessionToken?: string; sessionToken?: string;
role: "user" | "admin"; role: "user" | "admin";
}; };
@ -60,6 +61,7 @@ export type CommentDoc = Doc<"comments"> & {
content: string; content: string;
authorId: Id<"users">; authorId: Id<"users">;
authorName: string; authorName: string;
avatarUrl?: string;
parentId?: Id<"notes">; parentId?: Id<"notes">;
questionId?: Id<"questions">; questionId?: Id<"questions">;
parentCommentId?: Id<"comments">; parentCommentId?: Id<"comments">;

View file

@ -0,0 +1,261 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { uploadImage, mutation } from "$lib/api";
import Avatar from "$lib/components/Avatar.svelte";
import { currentUser, getToken, initAuth, logout, setCurrentUser } from "$lib/stores/auth";
import type { UserDoc } from "$lib/types";
import { get } from "svelte/store";
import { onMount } from "svelte";
let user: UserDoc | null = $state(null);
let loading = $state(true);
let name = $state("");
let avatarUrl: string | null = $state(null);
let avatarFile: File | null = $state(null);
let previewUrl: string | null = $state(null);
let profileSaving = $state(false);
let profileMessage = $state("");
let profileError = $state("");
let currentPassword = $state("");
let newPassword = $state("");
let confirmPassword = $state("");
let passwordSaving = $state(false);
let passwordMessage = $state("");
let passwordError = $state("");
onMount(async () => {
await initAuth();
user = get(currentUser);
if (!user) {
goto("/auth/login");
return;
}
name = user.name;
avatarUrl = user.avatarUrl ?? null;
loading = false;
});
function chooseAvatar(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0] ?? null;
if (!file) return;
if (!file.type.startsWith("image/")) {
profileError = "Please choose an image file";
return;
}
if (previewUrl) URL.revokeObjectURL(previewUrl);
avatarFile = file;
previewUrl = URL.createObjectURL(file);
profileError = "";
profileMessage = "";
}
function removeAvatar() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
avatarFile = null;
avatarUrl = null;
profileMessage = "";
}
async function saveProfile(event: SubmitEvent) {
event.preventDefault();
const token = getToken();
if (!token) return;
if (name.trim().length < 2) {
profileError = "Display name must be at least 2 characters";
return;
}
profileSaving = true;
profileError = "";
profileMessage = "";
try {
let nextAvatarUrl = avatarUrl;
if (avatarFile) nextAvatarUrl = await uploadImage(token, avatarFile);
const updated = (await mutation("users:updateProfile", {
token,
name: name.trim(),
avatarUrl: nextAvatarUrl,
})) as UserDoc;
user = updated;
name = updated.name;
avatarUrl = updated.avatarUrl ?? null;
avatarFile = null;
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
setCurrentUser(updated);
profileMessage = "Profile saved";
} catch (err: any) {
profileError = err.message ?? "Failed to save profile";
} finally {
profileSaving = false;
}
}
async function changePassword(event: SubmitEvent) {
event.preventDefault();
const token = getToken();
if (!token) return;
if (newPassword.length < 8) {
passwordError = "New password must be at least 8 characters";
return;
}
if (newPassword !== confirmPassword) {
passwordError = "New passwords do not match";
return;
}
passwordSaving = true;
passwordError = "";
passwordMessage = "";
try {
await mutation("users:changePassword", { token, currentPassword, newPassword });
currentPassword = "";
newPassword = "";
confirmPassword = "";
passwordMessage = "Password changed";
} catch (err: any) {
passwordError = err.message ?? "Failed to change password";
} finally {
passwordSaving = false;
}
}
function signOut() {
if (!confirm("Sign out?")) return;
logout();
goto("/");
}
</script>
<svelte:head>
<title>Account — Notebook</title>
</svelte:head>
<div class="page">
{#if loading}
<p class="kicker py-16">Loading</p>
{:else if user}
<div class="flex items-start justify-between gap-6">
<div>
<p class="kicker">Account</p>
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">Your profile</h1>
<p class="text-muted mt-2 text-sm">
<a href={`/users/${user._id}`} class="text-secondary hover:text-secondary-dark"
>View your public profile</a
>
</p>
</div>
<button type="button" onclick={signOut} class="btn-ghost shrink-0">Sign out</button>
</div>
<div class="mt-10 grid gap-12 md:grid-cols-2">
<form onsubmit={saveProfile} class="border-rule space-y-6 border-t pt-7">
<div>
<p class="kicker mb-3">Profile picture</p>
<div class="flex items-center gap-5">
<Avatar src={previewUrl ?? avatarUrl} name={name || user.name} size="lg" />
<div class="flex flex-wrap gap-2">
<label class="btn-secondary">
Choose image
<input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onchange={chooseAvatar}
class="sr-only"
/>
</label>
{#if avatarUrl || previewUrl}
<button type="button" onclick={removeAvatar} class="btn-ghost"
>Remove</button
>
{/if}
</div>
</div>
<p class="text-faint mt-2 text-xs">PNG, JPG, GIF or WebP. Maximum 10 MB.</p>
</div>
<div>
<label for="display-name" class="kicker mb-2 block">Display name</label>
<input
id="display-name"
bind:value={name}
maxlength="50"
autocomplete="name"
class="field"
required
/>
</div>
<div>
<label for="account-email" class="kicker mb-2 block">Email</label>
<input
id="account-email"
value={user.email}
class="field text-muted"
readonly
/>
<p class="text-faint mt-2 text-xs">
Your verified email cannot be changed here.
</p>
</div>
{#if profileError}<p class="text-primary text-sm">{profileError}</p>{/if}
{#if profileMessage}<p class="text-secondary text-sm">{profileMessage}</p>{/if}
<button type="submit" disabled={profileSaving} class="btn-primary">
{profileSaving ? "Saving..." : "Save profile"}
</button>
</form>
<form onsubmit={changePassword} class="border-rule space-y-5 border-t pt-7">
<div>
<p class="kicker">Password</p>
<h2 class="text-ink mt-2 font-serif text-2xl font-medium">Change password</h2>
</div>
<div>
<label for="current-password" class="kicker mb-2 block">Current password</label>
<input
id="current-password"
type="password"
bind:value={currentPassword}
autocomplete="current-password"
class="field"
required
/>
</div>
<div>
<label for="new-password" class="kicker mb-2 block">New password</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
autocomplete="new-password"
placeholder="At least 8 characters"
class="field"
required
/>
</div>
<div>
<label for="confirm-password" class="kicker mb-2 block"
>Confirm new password</label
>
<input
id="confirm-password"
type="password"
bind:value={confirmPassword}
autocomplete="new-password"
class="field"
required
/>
</div>
{#if passwordError}<p class="text-primary text-sm">{passwordError}</p>{/if}
{#if passwordMessage}<p class="text-secondary text-sm">{passwordMessage}</p>{/if}
<button type="submit" disabled={passwordSaving} class="btn-primary">
{passwordSaving ? "Changing..." : "Change password"}
</button>
</form>
</div>
{/if}
</div>

View file

@ -186,7 +186,10 @@
{/if} {/if}
{#if !editMode} {#if !editMode}
<p class="kicker mt-3"> <p class="kicker mt-3">
{note.authorName} · {timeAgo(note.createdAt)} <a href={`/users/${note.authorId}`} class="hover:text-primary"
>{note.authorName}</a
>
· {timeAgo(note.createdAt)}
{#if isAuthor} {#if isAuthor}
· ·
<button <button

View file

@ -172,7 +172,10 @@
{/if} {/if}
{#if !editMode} {#if !editMode}
<p class="kicker mt-3"> <p class="kicker mt-3">
{question.authorName} · {timeAgo(question.createdAt)} <a href={`/users/${question.authorId}`} class="hover:text-primary"
>{question.authorName}</a
>
· {timeAgo(question.createdAt)}
{#if isAuthor} {#if isAuthor}
· ·
<button <button

View file

@ -0,0 +1,160 @@
<script lang="ts">
import { page } from "$app/state";
import { query } from "$lib/api";
import Avatar from "$lib/components/Avatar.svelte";
import { previewMarkdown } from "$lib/markdown";
import { timeAgo } from "$lib/time";
import { onMount } from "svelte";
type Profile = {
_id: string;
name: string;
avatarUrl?: string;
_creationTime: number;
noteCount: number;
questionCount: number;
commentCount: number;
totalContributions: number;
posts: {
_id: string;
type: "note" | "question";
title: string;
createdAt: number;
voteCount: number;
}[];
comments: {
_id: string;
content: string;
createdAt: number;
targetType: "note" | "question";
targetId: string;
targetTitle: string | null;
}[];
};
let profile: Profile | null = $state(null);
let loading = $state(true);
let tab: "posts" | "comments" = $state("posts");
onMount(async () => {
profile = (await query("users:getPublicProfile", { id: page.params.id })) as Profile | null;
loading = false;
});
function contentPath(type: "note" | "question", id: string) {
return type === "note" ? `/notes/${id}` : `/questions/${id}`;
}
</script>
<svelte:head>
<title>{profile?.name ?? "Profile"} — Notebook</title>
</svelte:head>
<div class="page">
{#if loading}
<p class="kicker py-16">Loading</p>
{:else if profile}
<header class="flex items-center gap-6">
<Avatar src={profile.avatarUrl} name={profile.name} size="xl" />
<div>
<p class="kicker">Contributor</p>
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">{profile.name}</h1>
<p class="text-muted mt-2 text-sm">
Member since {new Date(profile._creationTime).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
})}
</p>
</div>
</header>
<section class="border-rule mt-10 grid grid-cols-2 border-y sm:grid-cols-4">
<div class="border-rule border-r px-4 py-5 first:pl-0">
<p class="text-ink font-serif text-3xl">{profile.totalContributions}</p>
<p class="kicker text-muted mt-1">Contributions</p>
</div>
<div class="border-rule border-r px-4 py-5">
<p class="text-ink font-serif text-3xl">{profile.noteCount}</p>
<p class="kicker text-muted mt-1">Notes</p>
</div>
<div class="border-rule border-r px-4 py-5">
<p class="text-ink font-serif text-3xl">{profile.questionCount}</p>
<p class="kicker text-muted mt-1">Questions</p>
</div>
<div class="px-4 py-5">
<p class="text-ink font-serif text-3xl">{profile.commentCount}</p>
<p class="kicker text-muted mt-1">Comments</p>
</div>
</section>
<div class="mt-10 flex gap-5">
<button
type="button"
onclick={() => (tab = "posts")}
class="kicker border-b-2 pb-2 {tab === 'posts'
? 'border-primary text-ink'
: 'text-muted border-transparent'}"
>
Posts ({profile.posts.length})
</button>
<button
type="button"
onclick={() => (tab = "comments")}
class="kicker border-b-2 pb-2 {tab === 'comments'
? 'border-primary text-ink'
: 'text-muted border-transparent'}"
>
Comments ({profile.commentCount})
</button>
</div>
<section class="mt-3">
{#if tab === "posts"}
{#if profile.posts.length === 0}
<p class="text-muted border-rule border-t py-8 text-sm">No posts yet.</p>
{:else}
{#each profile.posts as post}
<article class="border-rule border-t py-5">
<p class="kicker text-secondary">{post.type}</p>
<a
href={contentPath(post.type, post._id)}
class="text-ink hover:text-primary mt-1 block text-[15px] font-semibold"
>
{post.title}
</a>
<p class="text-faint mt-1 text-xs">
{timeAgo(post.createdAt)} · {post.voteCount} vote{post.voteCount ===
1
? ""
: "s"}
</p>
</article>
{/each}
{/if}
{:else if profile.comments.length === 0}
<p class="text-muted border-rule border-t py-8 text-sm">No comments yet.</p>
{:else}
{#each profile.comments as comment}
<article class="border-rule border-t py-5">
<p class="kicker">
On
<a
href={contentPath(comment.targetType, comment.targetId)}
class="hover:text-primary"
>
{comment.targetTitle ?? `a ${comment.targetType}`}
</a>
</p>
<p class="text-ink mt-2 text-sm leading-relaxed">
{previewMarkdown(comment.content)}
</p>
<p class="text-faint mt-1 text-xs">{timeAgo(comment.createdAt)}</p>
</article>
{/each}
{/if}
</section>
{:else}
<h1 class="text-ink font-serif text-3xl">Profile not found</h1>
<p class="text-muted mt-2 text-sm">This user does not exist.</p>
{/if}
</div>