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 { 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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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">;
|
||||||
|
|
|
||||||
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}
|
||||||
{#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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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