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.
420 lines
13 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|