Apply paper-and-ink design system across all surfaces
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>
This commit is contained in:
ginnoir
2026-05-07 01:03:21 -05:00
co-authored by Claude Opus 4.7
parent e130cda6c5
commit 9612a54e52
61 changed files with 4173 additions and 894 deletions
+11 -2
View File
@@ -1,5 +1,7 @@
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
import { getCurrentSession } from "@/lib/session";
import type { CalView } from "@/modules/_core/themes";
export default async function CalendarPage() {
const now = new Date();
@@ -8,10 +10,17 @@ export default async function CalendarPage() {
const to = new Date(now);
to.setMonth(to.getMonth() + 10);
const [calendars, events] = await Promise.all([
const [{ user }, calendars, events] = await Promise.all([
getCurrentSession(),
listCalendars(),
listEvents({ from, to, calendarIds: "all" }),
]);
return <CalendarShell calendars={calendars} events={events} />;
return (
<CalendarShell
calendars={calendars}
events={events}
defaultView={user.themeCalView as CalView}
/>
);
}
+58 -10
View File
@@ -1,13 +1,20 @@
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { getCurrentSession } from "@/lib/session";
import { parseDashboardLayout, computeDefaultLayout } from "@/lib/dashboard";
import { parseDashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
import { getWidget, getWidgetMetas } from "@/modules/_core";
import { getDashboardBySlug } from "@/app/d/actions";
import { DashboardEditor } from "@/components/dashboard-editor";
import { QuickAddFab } from "@/components/quick-add-fab";
import { EditDashboardButton } from "@/components/edit-dashboard-button";
import { DashboardSwitcher } from "@/components/dashboard-switcher";
import { DashboardTab } from "@/components/dashboard-tab";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { dashboards } from "@/modules/_core/schema";
import { asc, eq } from "drizzle-orm";
import type { DashLayout } from "@/modules/_core/themes";
const smColSpan: Record<number, string> = {
1: "sm:col-span-1",
@@ -24,6 +31,9 @@ const smColSpan: Record<number, string> = {
12: "sm:col-span-12",
};
const greetingForHour = (h: number) =>
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
export default async function DashboardPage({
params,
searchParams,
@@ -52,17 +62,55 @@ export default async function DashboardPage({
);
}
// For dashboard tabs sub-header, pull the user's dashboards (small list).
const session = await auth();
const userDashboards = session?.user?.id
? await db
.select({
id: dashboards.id,
name: dashboards.name,
slug: dashboards.slug,
isDefault: dashboards.isDefault,
position: dashboards.position,
})
.from(dashboards)
.where(eq(dashboards.userId, session.user.id))
.orderBy(asc(dashboards.position), asc(dashboards.createdAt))
: [];
const ctx = { userId: user.id, householdId: household.id };
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
const dashLayout = (user.themeDashLayout as DashLayout) ?? "classic";
const containerCls =
dashLayout === "glance" ? "max-w-[760px] mx-auto" : dashLayout === "split" ? "" : "";
const greeting = greetingForHour(new Date().getHours());
const today = new Date().toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
});
const firstName = (user.name ?? "").split(" ")[0] ?? "";
return (
<div className="p-4 sm:p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">{dashboard.name}</h1>
<div className="flex items-center gap-2">
<QuickAddFab />
<EditDashboardButton />
<div className={containerCls}>
{userDashboards.length > 1 && (
<div className="flex items-center gap-1 mb-4 overflow-x-auto">
{userDashboards.map((d) => (
<DashboardTab key={d.id} slug={d.slug} name={d.name} />
))}
<DashboardSwitcher dashboards={userDashboards} />
</div>
)}
<div className="mb-6 flex items-end justify-between gap-4 flex-wrap">
<div>
<h1 className="serif text-[26px] sm:text-[30px] leading-tight tracking-tight">
{greeting}
{firstName && `, ${firstName}`}.
</h1>
<p className="muted mt-1 text-[13px]">{today}</p>
</div>
<EditDashboardButton />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
@@ -73,8 +121,8 @@ export default async function DashboardPage({
return (
<div key={i} className={`col-span-1 ${colClass}`}>
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="text-base">{widget.title}</CardTitle>
<CardHeader>
<CardTitle>{widget.title}</CardTitle>
</CardHeader>
<CardContent>
<Suspense
+2 -1
View File
@@ -8,7 +8,8 @@ import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { getWidget } from "@/modules/_core";
import { dashboards } from "@/modules/_core/schema";
import { computeDefaultLayout, type DashboardLayout } from "@/lib/dashboard";
import { type DashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
export type DashboardMeta = {
id: string;
+1059 -168
View File
File diff suppressed because it is too large Load Diff
+86 -25
View File
@@ -1,8 +1,7 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Geist } from "next/font/google";
import { Inter, Source_Serif_4, Newsreader, Fraunces, JetBrains_Mono } from "next/font/google";
import { cn } from "@/lib/utils";
import { AppNav } from "@/components/app-nav";
import "@/modules"; // registers all module manifests
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
@@ -15,11 +14,34 @@ import { QuickAddSheet } from "@/components/quick-add-sheet";
import { CommandPalette } from "@/components/command-palette";
import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
const inter = Inter({ subsets: ["latin"], variable: "--sans-inter", display: "swap" });
const sourceSerif = Source_Serif_4({
subsets: ["latin"],
variable: "--serif-source",
display: "swap",
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--serif-newsreader",
display: "swap",
});
const fraunces = Fraunces({
subsets: ["latin"],
variable: "--serif-fraunces",
display: "swap",
});
const jetbrains = JetBrains_Mono({
subsets: ["latin"],
variable: "--mono-jb",
display: "swap",
});
export const viewport: Viewport = {
themeColor: "#4F46E5",
themeColor: "#1F1B16",
};
export const metadata: Metadata = {
@@ -36,34 +58,60 @@ export const metadata: Metadata = {
},
};
// Runs before paint reads localStorage / prefers-color-scheme and applies
// data-theme + dark class to <html> so signed-out pages also get the right theme.
// Pre-paint: read user's theme prefs from localStorage and apply data-* + .dark.
// Falls back to clay/serif-sans/regular/sidebar/system if nothing is stored.
const prePaintScript = `(function(){
try {
var t = localStorage.getItem('theme') || 'default';
var m = localStorage.getItem('themeMode') || 'system';
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', t);
if (dark) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
var palette = localStorage.getItem('themePalette') || localStorage.getItem('theme') || 'clay';
var mode = localStorage.getItem('themeMode') || 'system';
var fontPair = localStorage.getItem('themeFontPair') || 'serif-sans';
var density = localStorage.getItem('themeDensity') || 'regular';
var navStyle = localStorage.getItem('themeNavStyle') || 'rail-desktop';
var dataNav = navStyle === 'compact-rail' ? 'rail'
: navStyle === 'top-nav' ? 'top'
: navStyle === 'fab-only' ? 'fab' : 'sidebar';
if (window.matchMedia && window.matchMedia('(max-width: 759px)').matches) {
dataNav = navStyle === 'fab-only' ? 'fab' : 'bottom';
}
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
var html = document.documentElement;
html.setAttribute('data-theme', palette);
html.setAttribute('data-font-pair', fontPair);
html.setAttribute('data-density', density);
html.setAttribute('data-nav', dataNav);
if (dark) html.classList.add('dark'); else html.classList.remove('dark');
} catch(e) {}
})();`;
export default async function RootLayout({ children }: { children: React.ReactNode }) {
let theme = "default";
let themeMode = "system";
let palette: Palette = DEFAULT_THEME.palette;
let mode: ThemeMode = DEFAULT_THEME.mode;
let fontPair: FontPair = DEFAULT_THEME.fontPair;
let density: Density = DEFAULT_THEME.density;
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
let userDashboards: DashboardMeta[] = [];
let signedIn = false;
const session = await auth();
if (session?.user?.id) {
signedIn = true;
const [row] = await db
.select({ theme: users.theme, themeMode: users.themeMode })
.select({
themePalette: users.themePalette,
themeMode: users.themeMode,
themeFontPair: users.themeFontPair,
themeDensity: users.themeDensity,
themeNavStyle: users.themeNavStyle,
})
.from(users)
.where(eq(users.id, session.user.id))
.limit(1);
if (row) {
theme = row.theme;
themeMode = row.themeMode;
palette = row.themePalette as Palette;
mode = row.themeMode as ThemeMode;
fontPair = row.themeFontPair as FontPair;
density = row.themeDensity as Density;
navStyle = row.themeNavStyle as NavStyle;
}
userDashboards = await db
.select({
@@ -78,25 +126,38 @@ export default async function RootLayout({ children }: { children: React.ReactNo
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
}
// For system mode we can't know the preference on the server — the inline
// script will correct it before paint. We optimistically render light here.
const isDark = themeMode === "dark";
// Server-side initial dark guess: only for `dark` mode (system mode is corrected
// before paint by the inline script). Avoids a flash on signed-in users.
const isDark = mode === "dark";
const initialDataNav = navStyleToDataNav(navStyle);
const quickAdds = getQuickAdds();
const fontVars = cn(
inter.variable,
sourceSerif.variable,
newsreader.variable,
fraunces.variable,
jetbrains.variable,
);
return (
<html
lang="en"
data-theme={theme}
className={cn("font-sans", geist.variable, isDark ? "dark" : "")}
data-theme={palette}
data-font-pair={fontPair}
data-density={density}
data-nav={initialDataNav}
className={cn(fontVars, isDark ? "dark" : "")}
>
<head>
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
</head>
<body className="min-h-screen">
<body>
<QuickAddProvider actions={quickAdds}>
<AppNav dashboards={userDashboards} />
<main>{children}</main>
<AppShell signedIn={signedIn} navStyle={navStyle} dashboards={userDashboards}>
{children}
</AppShell>
<QuickAddSheet />
<CommandPalette />
<InstallPrompt />
+17 -5
View File
@@ -4,21 +4,32 @@ import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { DEV_LOGIN_COOKIE, isDevLoginEnabled } from "@/lib/dev-login-config";
import { createDevSession } from "@/lib/dev-login";
import { BrandMark } from "@/components/brand-mark";
export default function LoginPage() {
const devLoginEnabled = isDevLoginEnabled();
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8">
<div className="flex flex-col items-center gap-6">
<h1 className="text-4xl font-bold">famapp</h1>
<main
className="flex min-h-screen flex-col items-center justify-center p-8"
style={{ background: "var(--paper)" }}
>
<div
className="rounded-[var(--r-lg)] shadow-[var(--shadow-2)] p-10 max-w-sm w-full text-center"
style={{ background: "var(--card)", border: "0.5px solid var(--hair)" }}
>
<div className="flex justify-center mb-3">
<BrandMark />
</div>
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
<form
action={async () => {
"use server";
await signIn("authentik", { redirectTo: "/" });
}}
>
<Button type="submit" size="lg">
<Button type="submit" size="lg" className="w-full">
Sign in with SSO
</Button>
</form>
@@ -36,8 +47,9 @@ export default function LoginPage() {
});
redirect("/");
}}
className="mt-3"
>
<Button type="submit" variant="outline" size="lg">
<Button type="submit" variant="outline" size="lg" className="w-full">
Dev login
</Button>
</form>
+24 -20
View File
@@ -1,14 +1,17 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { eq } from "drizzle-orm";
import { resolveShareToken } from "@/modules/_core/share";
import { getEntityType } from "@/modules/_core/registry";
import { isRateLimited, recordFailure } from "@/lib/rate-limit";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { ShareFrame } from "@/components/share/share-frame";
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
// Rate-limit prefix length — must match the value used in middleware.
const RL_PREFIX_LEN = 8;
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
@@ -20,19 +23,12 @@ export default async function SharePage({ params }: { params: Promise<{ token: s
"0.0.0.0";
const rlKey = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
// Secondary rate-limit check in the Node.js runtime (failure-only bucket).
// The primary 429 enforcement lives in src/middleware.ts which counts all
// requests in the Edge runtime. This page tracks only failed token lookups,
// providing accurate per-failure accounting. The two buckets are independent
// (separate module instances across runtimes); a shared Redis store would
// unify them for multi-replica deployments.
if (isRateLimited(rlKey)) {
return <ShareRateLimitError />;
}
const resolved = await resolveShareToken(token);
if (!resolved) {
// Only failed lookups increment the failure bucket.
recordFailure(rlKey);
return <ShareError />;
}
@@ -48,24 +44,32 @@ export default async function SharePage({ params }: { params: Promise<{ token: s
return <ShareError />;
}
// Best-effort lookup of the creator's display name. Doesn't reveal email.
const [creator] = resolved.createdBy
? await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, resolved.createdBy))
.limit(1)
: [];
return (
<div className="min-h-screen">
<div className="border-b bg-background px-4 py-3">
<p className="text-xs text-muted-foreground">
Shared via famapp
{resolved.capabilities.write ? " · You can edit this" : " · View only"}
</p>
</div>
<ShareFrame
expiresAt={resolved.expiresAt}
capabilities={resolved.capabilities}
token={token}
sharedByName={creator?.name ?? null}
>
{entityReg.renderSharedView({ data, capabilities: resolved.capabilities, token })}
</div>
</ShareFrame>
);
}
function ShareRateLimitError() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
<h1 className="text-xl font-semibold">Too many requests</h1>
<p className="max-w-sm text-sm text-muted-foreground">
<h1 className="serif text-[28px] font-medium tracking-tight">Too many requests</h1>
<p className="max-w-sm text-[13.5px] muted">
You have made too many requests in a short period. Please wait a minute and try again.
</p>
</div>
@@ -75,8 +79,8 @@ function ShareRateLimitError() {
function ShareError({ message }: { message?: string }) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
<h1 className="text-xl font-semibold">Link not found</h1>
<p className="max-w-sm text-sm text-muted-foreground">
<h1 className="serif text-[28px] font-medium tracking-tight">Link not found</h1>
<p className="max-w-sm text-[13.5px] muted">
{message ??
"This share link may have expired or been revoked. Ask the sender for a new link."}
</p>
+62 -6
View File
@@ -4,17 +4,73 @@ import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes";
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
import {
VALID_PALETTES,
VALID_THEME_MODES,
VALID_FONT_PAIRS,
VALID_DENSITIES,
VALID_DASH_LAYOUTS,
VALID_CAL_VIEWS,
VALID_NAV_STYLES,
} from "@/modules/_core/themes";
import type {
Palette,
ThemeMode,
FontPair,
Density,
DashLayout,
CalView,
NavStyle,
} from "@/modules/_core/themes";
import { getCurrentSession } from "@/lib/session";
import { revokeShareLink } from "@/modules/_core/share";
export async function setUserTheme({ theme, mode }: { theme: ThemeId; mode: ThemeMode }) {
if (!VALID_THEME_IDS.has(theme)) throw new Error("Invalid theme");
if (!VALID_THEME_MODES.has(mode)) throw new Error("Invalid theme mode");
export interface ThemePatch {
palette?: Palette;
mode?: ThemeMode;
fontPair?: FontPair;
density?: Density;
dashLayout?: DashLayout;
calView?: CalView;
navStyle?: NavStyle;
}
export async function setUserTheme(patch: ThemePatch) {
const update: Record<string, string> = {};
if (patch.palette !== undefined) {
if (!VALID_PALETTES.has(patch.palette)) throw new Error("Invalid palette");
update["themePalette"] = patch.palette;
}
if (patch.mode !== undefined) {
if (!VALID_THEME_MODES.has(patch.mode)) throw new Error("Invalid theme mode");
update["themeMode"] = patch.mode;
}
if (patch.fontPair !== undefined) {
if (!VALID_FONT_PAIRS.has(patch.fontPair)) throw new Error("Invalid font pair");
update["themeFontPair"] = patch.fontPair;
}
if (patch.density !== undefined) {
if (!VALID_DENSITIES.has(patch.density)) throw new Error("Invalid density");
update["themeDensity"] = patch.density;
}
if (patch.dashLayout !== undefined) {
if (!VALID_DASH_LAYOUTS.has(patch.dashLayout)) throw new Error("Invalid dashboard layout");
update["themeDashLayout"] = patch.dashLayout;
}
if (patch.calView !== undefined) {
if (!VALID_CAL_VIEWS.has(patch.calView)) throw new Error("Invalid calendar view");
update["themeCalView"] = patch.calView;
}
if (patch.navStyle !== undefined) {
if (!VALID_NAV_STYLES.has(patch.navStyle)) throw new Error("Invalid nav style");
update["themeNavStyle"] = patch.navStyle;
}
if (Object.keys(update).length === 0) return;
const { user } = await getCurrentSession();
await db.update(users).set({ theme, themeMode: mode }).where(eq(users.id, user.id));
await db.update(users).set(update).where(eq(users.id, user.id));
}
export async function setCompletionVisibilityHours(hours: number): Promise<void> {
+290 -54
View File
@@ -7,44 +7,183 @@ 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";
export default async function SettingsPage() {
const { user } = await getCurrentSession();
const shareLinks = await getActiveShareLinks();
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="container max-w-2xl py-8 space-y-6">
<h1 className="text-2xl font-semibold">Settings</h1>
<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>Appearance</CardTitle>
<CardTitle>{household.name}</CardTitle>
<span className="meta">Self-hosted</span>
</CardHeader>
<CardContent>
<ThemePicker
initialTheme={user.theme as "default" | "warm"}
initialMode={user.themeMode as "light" | "dark" | "system"}
signedIn
/>
<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>Lists</CardTitle>
<CardTitle>You</CardTitle>
</CardHeader>
<CardContent>
<CompletionDelaySetting initialHours={user.completionVisibilityHours} />
<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>
<CardTitle>Push notifications</CardTitle>
</CardHeader>
<CardContent>
<PushOptIn vapidKey={vapidKey} />
@@ -53,7 +192,7 @@ export default async function SettingsPage() {
<Card>
<CardHeader>
<CardTitle>Notification Channels</CardTitle>
<CardTitle>Notification channels</CardTitle>
</CardHeader>
<CardContent>
<NotifyChannelToggles
@@ -64,52 +203,149 @@ export default async function SettingsPage() {
/>
</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>Active Share Links</CardTitle>
<CardTitle>Lists</CardTitle>
</CardHeader>
<CardContent>
{shareLinks.length === 0 ? (
<p className="text-sm text-muted-foreground">No active share links.</p>
) : (
<ul className="space-y-3">
{shareLinks.map((link) => {
const registration = getEntityType(link.entityType);
const label = registration?.label.singular ?? link.entityType;
return (
<li key={link.id} className="flex items-center justify-between gap-4 text-sm">
<div className="min-w-0">
<span className="font-medium">{label}</span>
<span className="text-muted-foreground ml-2">
{link.capabilities.write ? "read + write" : "read-only"}
</span>
{link.expiresAt && (
<span className="text-muted-foreground ml-2">
· expires {link.expiresAt.toLocaleDateString()}
</span>
)}
</div>
<form action={revokeShareLinkAction}>
<input type="hidden" name="id" value={link.id} />
<Button variant="destructive" size="sm" type="submit">
Revoke
</Button>
</form>
</li>
);
})}
</ul>
)}
<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>
</>
);
}
<Link
href="/settings/household"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
>
Household Settings
</Link>
</div>
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>
);
}