Files
sofa/app/(pages)/settings/page.tsx
T
jake 84c4356a9f Add user avatar upload to account settings and nav bar
- Add `uploadAvatarAction` and `removeAvatarAction` server actions;
  store files under `DATA_DIR/avatars/{userId}.{ext}` via Bun.write
- Add `GET /api/avatars/[userId]` route that scans the avatar dir with
  Bun.Glob and serves with immutable Cache-Control (cache-busted via
  query param)
- Refactor AccountSection with hover overlay, AnimatePresence fade,
  and file input; click avatar to upload (no image) or remove (has image)
- Replace NavBar user initial badge with Avatar + DropdownMenu showing
  name, email, settings link, and sign-out
- Thread `userEmail` and `userImage` props through AuthenticatedShell
  → NavBar and SettingsPage → AccountSection
2026-03-08 11:02:33 -04:00

277 lines
9.6 KiB
TypeScript

import {
IconDatabaseExport,
IconServer2,
IconShieldLock,
} from "@tabler/icons-react";
import { desc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { TmdbLogo } from "@/components/tmdb-logo";
import { Card } from "@/components/ui/card";
import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { integrationEvents, integrations } from "@/lib/db/schema";
import { listBackups } from "@/lib/services/backup";
import { getSetting } from "@/lib/services/settings";
import { getSystemHealth } from "@/lib/services/system-health";
import {
getCachedUpdateCheck,
isUpdateCheckEnabled,
} from "@/lib/services/update-check";
import { AccountSection } from "./_components/account-section";
import { BackupRestoreSection } from "./_components/backup-restore-section";
import { BackupScheduleSection } from "./_components/backup-schedule-section";
import { BackupSection } from "./_components/backup-section";
import { IntegrationsSection } from "./_components/integrations-section";
import { RegistrationSection } from "./_components/registration-section";
import { SettingsShell } from "./_components/settings-shell";
import {
SkeletonCards,
SystemHealthCards,
} from "./_components/system-health-section";
import { UpdateCheckSection } from "./_components/update-check-section";
export default async function SettingsPage() {
const session = await getSession();
if (!session?.user) redirect("/login");
const isAdmin = session.user.role === "admin";
const connRows = db
.select()
.from(integrations)
.where(eq(integrations.userId, session.user.id))
.all();
const connIds = connRows.map((c) => c.id);
// Fetch only the 10 most recent events per connection (index-optimized)
const eventsByConn = new Map<
string,
(typeof integrationEvents.$inferSelect)[]
>();
for (const connId of connIds) {
const events = db
.select()
.from(integrationEvents)
.where(eq(integrationEvents.integrationId, connId))
.orderBy(desc(integrationEvents.receivedAt))
.limit(10)
.all();
eventsByConn.set(connId, events);
}
const connections = connRows.map((conn) => ({
id: conn.id,
provider: conn.provider,
type: conn.type,
token: conn.token,
enabled: conn.enabled,
lastEventAt: conn.lastEventAt?.toISOString() ?? null,
recentEvents: (eventsByConn.get(conn.id) ?? []).map((e) => ({
id: e.id,
eventType: e.eventType,
mediaType: e.mediaType,
mediaTitle: e.mediaTitle,
status: e.status,
receivedAt: e.receivedAt.toISOString(),
})),
}));
const registrationOpen = isAdmin
? getSetting("registrationOpen") === "true"
: false;
const backups = isAdmin ? await listBackups() : [];
const scheduledBackupsEnabled = isAdmin
? getSetting("scheduledBackups") === "true"
: false;
const maxBackupRetention = isAdmin
? Number.parseInt(getSetting("maxBackupRetention") ?? "7", 10)
: 7;
const backupFrequency = isAdmin
? (getSetting("backupScheduleFrequency") ?? "1d")
: "1d";
const backupTime = isAdmin
? (getSetting("backupScheduleTime") ?? "02:00")
: "02:00";
const backupDow = isAdmin
? Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10)
: 0;
const updateCheckEnabled = isAdmin ? isUpdateCheckEnabled() : true;
const updateCheck =
isAdmin && updateCheckEnabled ? getCachedUpdateCheck() : null;
const GITHUB_REPO = "jakejarvis/sofa";
const APP_VERSION = process.env.APP_VERSION || "0.0.0";
const GIT_COMMIT_SHA = process.env.GIT_COMMIT_SHA?.slice(0, 7) || "";
return (
<SettingsShell
footer={
<footer className="border-border/50 border-t pt-6 pb-2 text-center text-muted-foreground text-xs">
<p>
<a
href={`https://github.com/${GITHUB_REPO}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary/80 transition-colors hover:text-primary"
>
Sofa
</a>{" "}
v{APP_VERSION}
{GIT_COMMIT_SHA && (
<>
{" "}
(
<a
href={`https://github.com/${GITHUB_REPO}/commit/${GIT_COMMIT_SHA}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono transition-colors hover:text-primary"
>
{GIT_COMMIT_SHA}
</a>
)
</>
)}
{updateCheck?.updateAvailable && (
<span className="ml-1.5 inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 font-medium text-[10px] text-primary">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary" />
</span>
<a
href={
updateCheck.releaseUrl ??
`https://github.com/${GITHUB_REPO}/releases`
}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
v{updateCheck.latestVersion} available
</a>
</span>
)}
</p>
<div className="mt-4 flex flex-col items-center gap-2">
<a
href="https://www.themoviedb.org/"
target="_blank"
rel="noopener noreferrer"
className="transition-opacity hover:opacity-70"
>
<TmdbLogo className="h-3" />
</a>
<p className="text-[10px] text-muted-foreground leading-relaxed">
This product uses the TMDB API but is not endorsed or certified by
TMDB.
</p>
</div>
</footer>
}
>
<AccountSection
user={{
name: session.user.name,
email: session.user.email,
image: session.user.image || undefined,
createdAt: session.user.createdAt.toISOString(),
role: session.user.role ?? undefined,
}}
/>
<IntegrationsSection initialConnections={connections} />
{isAdmin && (
<>
{/* Server health */}
<div>
<div className="mb-3 flex items-center gap-2">
<IconServer2
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
Server
</h2>
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 font-medium text-[10px] text-primary">
Admin only
</span>
</div>
<Suspense fallback={<SkeletonCards />}>
<SystemHealthLoader />
</Suspense>
</div>
{/* Security */}
<div>
<div className="mb-3 flex items-center gap-2">
<IconShieldLock
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
Security
</h2>
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 font-medium text-[10px] text-primary">
Admin only
</span>
</div>
<div className="space-y-3">
<Card className="border-l-2 border-l-primary/30">
<RegistrationSection
initialRegistrationOpen={registrationOpen}
/>
</Card>
<Card className="border-l-2 border-l-primary/30">
<UpdateCheckSection initialEnabled={updateCheckEnabled} />
</Card>
</div>
</div>
{/* Backups */}
<div>
<div className="mb-3 flex items-center gap-2">
<IconDatabaseExport
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
Backups
</h2>
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 font-medium text-[10px] text-primary">
Admin only
</span>
</div>
<div className="space-y-3">
<Card className="border-l-2 border-l-primary/30">
<BackupSection initialBackups={backups} />
</Card>
<Card className="border-l-2 border-l-primary/30">
<BackupScheduleSection
initialScheduledEnabled={scheduledBackupsEnabled}
initialMaxRetention={maxBackupRetention}
initialFrequency={
backupFrequency as "6h" | "12h" | "1d" | "7d"
}
initialTime={backupTime}
initialDow={backupDow}
/>
</Card>
<Card className="border-l-2 border-l-primary/30">
<BackupRestoreSection />
</Card>
</div>
</div>
</>
)}
</SettingsShell>
);
}
async function SystemHealthLoader() {
const data = await getSystemHealth();
return <SystemHealthCards initialData={data} />;
}