mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
- Stack title-hero poster/metadata vertically on mobile (flex-col md:flex-row), switch backdrop tall breakpoint from sm to md - Extract bio expand/collapse logic into reusable ExpandableText component; use it in PersonHero and TitleHero - Fix ContinueWatchingCard image to use fill + sizes instead of fixed width/height to avoid layout shift - Add fade-out gradient on genre chip scrollbar edge on mobile - Show scaled-down ambient glow on mobile instead of hiding it entirely - Humanize knownForDepartment labels (Acting→Actor, Directing→Director, etc.) - Change cast member name from truncate to line-clamp-2 for wrapping - Hide tooltip arrow via [&>:last-child]:hidden instead of color-matching it - Apply safe-area-inset padding to HeroBanner content on notched devices
276 lines
9.5 KiB
TypeScript
276 lines
9.5 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,
|
|
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} />;
|
|
}
|