Apply paper-and-ink design system across all surfaces
Release / build-and-push (push) Has been cancelled
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:
co-authored by
Claude Opus 4.7
parent
e130cda6c5
commit
9612a54e52
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user