diff --git a/app/(pages)/layout.tsx b/app/(pages)/layout.tsx index 92d5ca2..04d0598 100644 --- a/app/(pages)/layout.tsx +++ b/app/(pages)/layout.tsx @@ -28,7 +28,11 @@ async function AuthenticatedShell({ children }: { children: React.ReactNode }) { return ( <>
- + {/* Ambient glow — smaller on mobile to add warmth without overwhelming */}
(null); const memberSince = new Date(user.createdAt).toLocaleDateString(undefined, { year: "numeric", @@ -25,6 +51,41 @@ export function AccountSection({ }); const initial = user.name?.charAt(0).toUpperCase() ?? "?"; + function handleFileSelect(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + const formData = new FormData(); + formData.append("file", file); + + startTransition(async () => { + try { + const result = await uploadAvatarAction(formData); + setAvatarUrl(result.imageUrl); + toast.success("Profile picture updated"); + router.refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : "Upload failed"; + toast.error(message); + } finally { + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }); + } + + function handleRemoveAvatar() { + startTransition(async () => { + try { + await removeAvatarAction(); + setAvatarUrl(undefined); + toast.success("Profile picture removed"); + router.refresh(); + } catch { + toast.error("Failed to remove profile picture"); + } + }); + } + return (
@@ -35,9 +96,71 @@ export function AccountSection({
-
- {initial} -
+ {/* Avatar: click to upload (no avatar) or remove (has avatar) */} + + fileInputRef.current?.click() + } + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + disabled={isPending} + /> + } + className="relative shrink-0 cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" + aria-label={ + avatarUrl ? "Remove profile picture" : "Upload profile picture" + } + > + + {avatarUrl && !isPending && ( + + )} + + {initial} + + + + + {(isHovered || isPending) && ( + + {isPending ? ( + + ) : avatarUrl ? ( + + ) : ( + + )} + + )} + + + + {avatarUrl ? "Remove picture" : "Upload picture"} + + + + +
{user.name} @@ -52,6 +175,7 @@ export function AccountSection({ Member since {memberSince}

+ - - } - className="hidden items-center gap-1.5 rounded-md px-2 py-1.5 text-muted-foreground text-sm leading-none transition-colors hover:text-foreground sm:inline-flex" + {/* User avatar dropdown */} + + - {userName} - - - Settings - - - { - await signOut(); - router.push("/"); - router.refresh(); - }} - aria-label="Sign out" - className="hidden h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:inline-flex" - > - - - Log out - + + {userImage && } + + {initial} + + + + +
+ + {userImage && } + + {initial} + + +
+

+ {userName} +

+

+ {userEmail} +

+
+
+ + } + className="cursor-pointer text-[13px]" + > + + Settings + + + { + await signOut(); + router.push("/"); + router.refresh(); + }} + className="cursor-pointer text-[13px]" + > + + Sign out + +
+ + {/* Mobile: simple avatar link to settings */} + + + {userImage && } + + {initial} + + +
diff --git a/lib/actions/settings.ts b/lib/actions/settings.ts index 7662d49..9ad1770 100644 --- a/lib/actions/settings.ts +++ b/lib/actions/settings.ts @@ -1,7 +1,11 @@ "use server"; +import { mkdir, rename } from "node:fs/promises"; +import path from "node:path"; import { and, eq } from "drizzle-orm"; +import { headers } from "next/headers"; import { z } from "zod"; +import { auth } from "@/lib/auth/server"; import { requireAdmin, requireSession } from "@/lib/auth/session"; import { type BackupFrequency, rescheduleBackup, triggerJob } from "@/lib/cron"; import { db } from "@/lib/db/client"; @@ -261,3 +265,75 @@ export async function getUpdateCheckAction(): Promise return null; } } + +// --- Avatar actions --- + +const DATA_DIR = process.env.DATA_DIR || "./data"; +const AVATAR_DIR = path.join(DATA_DIR, "avatars"); +const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB +const ALLOWED_AVATAR_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]); +const MIME_TO_EXT: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", +}; + +export async function uploadAvatarAction( + formData: FormData, +): Promise<{ imageUrl: string }> { + const session = await requireSession(); + const file = formData.get("file") as File | null; + if (!file) throw new Error("No file provided"); + if (file.size > MAX_AVATAR_SIZE) throw new Error("File too large (max 2MB)"); + if (!ALLOWED_AVATAR_TYPES.has(file.type)) + throw new Error("Invalid file type. Use JPEG, PNG, WebP, or GIF."); + + await mkdir(AVATAR_DIR, { recursive: true }); + + // Remove any existing avatar for this user + const glob = new Bun.Glob(`${session.user.id}.*`); + const existing = await Array.fromAsync(glob.scan(AVATAR_DIR)); + for (const match of existing) { + await Bun.file(path.join(AVATAR_DIR, match)).delete(); + } + + // Write new avatar (atomic: temp file + rename) + const ext = MIME_TO_EXT[file.type] || "jpg"; + const filename = `${session.user.id}.${ext}`; + const filePath = path.join(AVATAR_DIR, filename); + const tmpPath = `${filePath}.tmp.${Date.now()}`; + await Bun.write(tmpPath, file); + await rename(tmpPath, filePath); + + // Update user via Better Auth (updates DB + refreshes session cookie) + const imageUrl = `/api/avatars/${session.user.id}?v=${Date.now()}`; + await auth.api.updateUser({ + body: { image: imageUrl }, + headers: await headers(), + }); + + return { imageUrl }; +} + +export async function removeAvatarAction(): Promise { + const session = await requireSession(); + + // Remove file from disk + const glob = new Bun.Glob(`${session.user.id}.*`); + const matches = await Array.fromAsync(glob.scan(AVATAR_DIR)); + for (const match of matches) { + await Bun.file(path.join(AVATAR_DIR, match)).delete(); + } + + // Clear user via Better Auth (updates DB + refreshes session cookie) + await auth.api.updateUser({ + body: { image: "" }, + headers: await headers(), + }); +} diff --git a/lib/services/system-health.ts b/lib/services/system-health.ts index bbf9e84..a895a15 100644 --- a/lib/services/system-health.ts +++ b/lib/services/system-health.ts @@ -1,4 +1,4 @@ -import { access, constants, readdir, stat } from "node:fs/promises"; +import { access, constants, readdir } from "node:fs/promises"; import path from "node:path"; import { count, desc, eq } from "drizzle-orm"; import { db } from "@/lib/db/client"; @@ -217,14 +217,7 @@ async function getImageCacheHealth(): Promise { try { const files = await readdir(dir); const sizes = await Promise.all( - files.map(async (file) => { - try { - const s = await stat(path.join(dir, file)); - return s.isFile() ? s.size : 0; - } catch { - return 0; - } - }), + files.map((file) => Bun.file(path.join(dir, file)).size), ); const sizeBytes = sizes.reduce((sum, s) => sum + s, 0); categories[category] = { count: files.length, sizeBytes };