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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+86
-25
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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