Release / build-and-push (push) Has been cancelled
Replaces the generic shadcn/ui gray theme + horizontal top-bar shell with the paper-and-ink language from the Claude Design handoff bundle: warm off-white paper, near-black ink, Source Serif 4 + Inter, hairline borders, muted ink accents (clay/indigo/sage/plum/ochre) used functionally for calendars and share scopes. Theme switcher expanded from 2 dimensions (theme × mode) to 7: palette × mode × fontPair × density × dashLayout × calView × navStyle. All exposed in Settings → Appearance and persisted on the users row. Pre-paint script applies all four data-* attributes from localStorage so reload doesn't flash. App shell restructured to a CSS-grid driven by data-nav on <html>: sidebar on desktop, bottom-nav + FAB under 760px. Four desktop nav modes wired (sidebar/rail/top/fab-only). Topbar gets a search-→-CommandPalette button, notification bell, "+ New" quick-add, avatar. Dashboard, calendar, lists, notes, settings, login, public share viewer, and quick-add sheet all reskinned. Dashboard editor gains a Preset menu (classic/split/glance) that fills the layout from the registered widgets. FullCalendar wrapped in .fc-skin and inherits all paper-and-ink tokens via CSS variable overrides. Public share viewer (/s/<token>) rebuilt around ShareFrame: expiration banner, brand strip, eyebrow chip, 38px serif title, mini-day + mini-map cards, share-rows. Schema: drops users.theme; adds theme_palette, theme_font_pair, theme_density, theme_dash_layout, theme_cal_view, theme_nav_style with defaults that match the design (clay / serif-sans / regular / classic / month / rail-desktop). Migration 0014_paper_ink_theme. Middleware sets x-pathname so the AppShell server component can render bare for /s/* and /login without a route-group refactor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
352 lines
12 KiB
TypeScript
352 lines
12 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 { revokeShareLinkAction } from "./actions";
|
|
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 } 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 } = 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} userName={user.name} />}
|
|
{section === "sharing" && <SharingSection />}
|
|
{section === "notifications" && (
|
|
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
|
|
)}
|
|
{section === "calendars" && <CalendarsAndListsSection />}
|
|
{section === "appearance" && <AppearanceSection user={user} />}
|
|
{section === "data" && <DataSection />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HouseholdSection({
|
|
household,
|
|
userName,
|
|
}: {
|
|
household: { id: string; name: string };
|
|
userName: 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 }}>
|
|
<span className="avatar avatar-lg" style={{ background: "var(--c-household)" }}>
|
|
{(userName ?? "?").trim()[0]?.toUpperCase()}
|
|
</span>
|
|
<div className="label">
|
|
<div className="t">{userName ?? "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,
|
|
vapidKey,
|
|
ntfyConfigured,
|
|
}: {
|
|
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
|
|
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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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;
|
|
};
|
|
}) {
|
|
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>
|
|
);
|
|
}
|
|
|
|
function DataSection() {
|
|
return (
|
|
<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>
|
|
);
|
|
}
|