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 };