Files
famapp/src/app/settings/page.tsx
T
ginnoir e1c2a090fb feat: p2 batch 28-31 reminders lists comments bang stats
Calendar events support multiple reminder offsets with presets and per-user defaults.

Lists index adds inline task entry and list property editing.

Generic entity comments on list detail. New bangs.stats dashboard widget.

Closes Gitea #28, #29, #30, #31. Migrations 0022 and 0023.
2026-07-04 22:17:35 -05:00

420 lines
13 KiB
TypeScript

import { getCurrentSession } from "@/lib/session";
import { getActiveShareLinks } from "@/modules/_core/share";
import { getEntityType } from "@/modules/_core/registry";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ThemePicker } from "@/components/theme-picker";
import { CompletionDelaySetting } from "@/components/completion-delay-setting";
import { PushOptIn } from "@/components/push-opt-in";
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
import { ResponsiveSidebar } from "@/components/settings-section";
import type { SectionId } from "@/components/settings-section";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions";
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
import { ApiTokenSettings } from "@/components/api-token-settings";
import { AssistantOptIn } from "@/components/assistant-opt-in";
import { DefaultEventRemindersSetting } from "@/components/default-event-reminders-setting";
import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries";
import Link from "next/link";
import { NavIcon } from "@/components/nav-icon";
import {
Mail,
Globe,
History,
Sun,
Bell,
Pencil,
Lock,
Plus,
KeyRound,
MessageCircle,
} from "lucide-react";
const VALID_SECTIONS = new Set<SectionId>([
"household",
"sharing",
"notifications",
"calendars",
"appearance",
"data",
]);
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ s?: string }>;
}) {
const sp = await searchParams;
const section: SectionId = VALID_SECTIONS.has(sp.s as SectionId)
? (sp.s as SectionId)
: "household";
const { user, household, role } = await getCurrentSession();
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
return (
<div className="grid gap-5 sm:grid-cols-[220px_1fr]">
<ResponsiveSidebar active={section} />
<div className="space-y-4">
{section === "household" && <HouseholdSection household={household} user={user} />}
{section === "sharing" && <SharingSection />}
{section === "notifications" && (
<NotificationsSection
user={user}
defaultEventReminderOffsets={user.defaultEventReminderOffsets ?? [30]}
vapidKey={vapidKey}
ntfyConfigured={ntfyConfigured}
/>
)}
{section === "calendars" && <CalendarsAndListsSection />}
{section === "appearance" && <AppearanceSection user={user} />}
{section === "data" && <DataSection householdId={household.id} role={role} />}
</div>
</div>
);
}
function HouseholdSection({
household,
user,
}: {
household: { id: string; name: string };
user: { name: string | null; image: string | null };
}) {
return (
<>
<Card>
<CardHeader>
<CardTitle>{household.name}</CardTitle>
<span className="meta">Self-hosted</span>
</CardHeader>
<CardContent>
<p className="muted text-[13px] mb-3">
Household name shown on share links and the iOS PWA.
</p>
<Link
href="/settings/household"
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-[13px] font-medium border-[0.5px] hover:bg-[var(--shade)]"
style={{ borderColor: "var(--hair-2)" }}
>
<Pencil className="size-3.5" />
Edit household & members
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>You</CardTitle>
</CardHeader>
<CardContent>
<div className="set-row" style={{ borderBottom: "0", padding: 0 }}>
<AvatarFallbackWithName name={user.name ?? undefined} image={user.image} />
<div className="label">
<div className="t">{user.name ?? "Anonymous"}</div>
<div className="d">Signed in via Authentik</div>
</div>
</div>
</CardContent>
</Card>
</>
);
}
async function SharingSection() {
const shareLinks = await getActiveShareLinks();
return (
<Card>
<CardHeader>
<CardTitle>Public share links</CardTitle>
</CardHeader>
<CardContent>
<p className="muted text-[12.5px] mb-3">
Share any calendar, list, note, or event with people outside the household. Links expire
on their own no logins needed.
</p>
{shareLinks.length === 0 ? (
<p className="muted text-[13px]">No active share links.</p>
) : (
<div className="flex flex-col gap-2">
{shareLinks.map((link) => {
const registration = getEntityType(link.entityType);
const label = registration?.label.singular ?? link.entityType;
const iconName = link.entityType.startsWith("calendar.")
? "calendar"
: link.entityType.startsWith("notes.")
? "note"
: link.entityType.startsWith("lists.")
? "list"
: "link";
return (
<div
key={link.id}
className="flex items-center gap-3 rounded-[var(--r-md)] border-[0.5px] p-3"
style={{ borderColor: "var(--hair)" }}
>
<div
className="size-9 rounded-md flex items-center justify-center shrink-0"
style={{ background: "var(--paper-2)" }}
>
<NavIcon name={iconName} className="size-4 text-[var(--ink-soft)]" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13.5px] font-medium">{label}</div>
<div className="flex flex-wrap gap-2 items-center text-[11.5px] muted mt-0.5">
<code style={{ fontFamily: "var(--mono)" }}>
{link.capabilities.write ? "edit" : "view-only"}
</code>
{link.expiresAt && (
<span>· expires {link.expiresAt.toLocaleDateString()}</span>
)}
</div>
</div>
<form action={revokeShareLinkAction}>
<input type="hidden" name="id" value={link.id} />
<Button variant="destructive" size="sm" type="submit">
Revoke
</Button>
</form>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
function NotificationsSection({
user,
defaultEventReminderOffsets,
vapidKey,
ntfyConfigured,
}: {
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
defaultEventReminderOffsets: number[];
vapidKey: string;
ntfyConfigured: boolean;
}) {
return (
<>
<Card>
<CardHeader>
<CardTitle>Push notifications</CardTitle>
</CardHeader>
<CardContent>
<PushOptIn vapidKey={vapidKey} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notification channels</CardTitle>
</CardHeader>
<CardContent>
<NotifyChannelToggles
push={user.notifPush}
inapp={user.notifInApp}
ntfy={user.notifNtfy}
ntfyConfigured={ntfyConfigured}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Default event reminders</CardTitle>
</CardHeader>
<CardContent>
<DefaultEventRemindersSetting
key={defaultEventReminderOffsets.join(",")}
initialOffsets={defaultEventReminderOffsets}
/>
</CardContent>
</Card>
</>
);
}
async function CalendarsAndListsSection() {
const [calendars, lists] = await Promise.all([listCalendars(), listLists()]);
return (
<>
<Card>
<CardHeader>
<CardTitle>Calendars</CardTitle>
<Link
href="/calendar"
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[12.5px] hover:bg-[var(--shade)]"
>
<Plus className="size-3.5" />
New
</Link>
</CardHeader>
<div>
{calendars.length === 0 ? (
<div className="muted px-[14px] py-4 text-[13px]">No calendars yet.</div>
) : (
calendars.map((c) => (
<div key={c.id} className="set-row">
<span
className="dot"
style={{ background: c.color ?? "var(--c-household)", width: 12, height: 12 }}
/>
<div className="label">
<div className="t">{c.name}</div>
<div className="d">
{c.visibility === "private" ? "Private" : "Household · everyone sees it"}
</div>
</div>
{c.visibility === "private" && <Lock className="size-3.5 text-[var(--ink-mute)]" />}
</div>
))
)}
</div>
</Card>
<Card>
<CardHeader>
<CardTitle>Lists</CardTitle>
</CardHeader>
<CardContent>
<CompletionDelaySetting initialHours={24} />
</CardContent>
<div>
{lists.length === 0 ? (
<div className="muted px-[14px] py-4 text-[13px]">No lists yet.</div>
) : (
lists.map((l) => (
<div key={l.id} className="set-row">
<NavIcon
name={l.type === "shopping" ? "cart" : "check-square"}
className="size-4 text-[var(--ink-soft)]"
/>
<div className="label">
<div className="t">{l.name}</div>
<div className="d">
{l.type} · {l.openCount} open
</div>
</div>
</div>
))
)}
</div>
</Card>
</>
);
}
function AppearanceSection({
user,
}: {
user: {
themePalette: string;
themeMode: string;
themeFontPair: string;
themeDensity: string;
themeDashLayout: string;
themeCalView: string;
themeNavStyle: string;
assistantEnabled: boolean;
};
}) {
return (
<>
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<Sun className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<ThemePicker
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
initialMode={user.themeMode as "light" | "dark" | "system"}
initialFontPair={
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
}
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
initialCalView={user.themeCalView as "month" | "week" | "day"}
initialNavStyle={
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
}
signedIn
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Assistant</CardTitle>
<MessageCircle className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<AssistantOptIn enabled={user.assistantEnabled} />
</CardContent>
</Card>
</>
);
}
async function DataSection({
householdId,
role,
}: {
householdId: string;
role: "owner" | "member";
}) {
const tokenStatus = role === "owner" ? await getHouseholdApiTokenStatus(householdId) : null;
return (
<>
{role === "owner" && tokenStatus && (
<Card>
<CardHeader>
<CardTitle>API access</CardTitle>
<KeyRound className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent className="p-0">
<ApiTokenSettings status={tokenStatus} />
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Data & backups</CardTitle>
</CardHeader>
<div>
<div className="set-row">
<History className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Auto-backup</div>
<div className="d">Daily 03:00 /var/backups/famapp/. Configured via host cron.</div>
</div>
</div>
<div className="set-row">
<Globe className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Server</div>
<div className="d">Self-hosted via Docker Compose</div>
</div>
</div>
<div className="set-row" style={{ borderBottom: "0" }}>
<Mail className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Export</div>
<div className="d">Not yet implemented coming in v0.5</div>
</div>
<Bell className="size-4 text-[var(--ink-faint)]" />
</div>
</div>
</Card>
</>
);
}