mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
added account page, profile picture
This commit is contained in:
parent
6d0b5a289a
commit
e8fde1121c
11 changed files with 661 additions and 50 deletions
52
src/lib/components/Avatar.svelte
Normal file
52
src/lib/components/Avatar.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">;
|
||||
|
|
|
|||
261
src/routes/account/+page.svelte
Normal file
261
src/routes/account/+page.svelte
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
160
src/routes/users/[id]/+page.svelte
Normal file
160
src/routes/users/[id]/+page.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue