added account page, profile picture

This commit is contained in:
liyunze 2026-09-02 23:11:14 +08:00
parent 6d0b5a289a
commit e8fde1121c
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 { 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 @@
<div class="py-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">
<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}
· Edited
{/if}

View file

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

View file

@ -71,7 +71,7 @@ function normalizeEmail(email: string): string {
function requireAuth(db: Db, token: string): Record<string, any> {
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<string, any> | 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<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 ----
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<string, any> | undefined;
if (user) {
@ -1161,6 +1275,9 @@ const handlers: Record<string, Handler> = {
"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,

View file

@ -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")) {

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() {
localStorage.removeItem(STORAGE_KEY);
currentUser.set(null);

View file

@ -8,6 +8,7 @@ export type Doc<T extends string> = {
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">;

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 !editMode}
<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}
·
<button

View file

@ -172,7 +172,10 @@
{/if}
{#if !editMode}
<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}
·
<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>