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
@@ -0,0 +1,155 @@
|
||||
import Link from "next/link";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { getRegistry } from "@/modules/_core/registry";
|
||||
import { householdMembers, households, users } from "@/modules/_core/schema";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { NavIcon } from "@/components/nav-icon";
|
||||
import { NavLink } from "@/components/nav-link";
|
||||
|
||||
type Variant = "side" | "top";
|
||||
|
||||
const PRIMARY_NAV: Array<{ href: string; label: string; icon: string }> = [
|
||||
{ href: "/", label: "Dashboard", icon: "home" },
|
||||
];
|
||||
|
||||
const SECONDARY_NAV: Array<{ href: string; label: string; icon: string }> = [
|
||||
{ href: "/settings#sharing", label: "Share links", icon: "link" },
|
||||
];
|
||||
|
||||
async function getHouseholdInfo(userId: string) {
|
||||
const [row] = await db
|
||||
.select({ household: households })
|
||||
.from(householdMembers)
|
||||
.innerJoin(households, eq(householdMembers.householdId, households.id))
|
||||
.where(eq(householdMembers.userId, userId))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const members = await db
|
||||
.select({ id: users.id, name: users.name, image: users.image, email: users.email })
|
||||
.from(householdMembers)
|
||||
.innerJoin(users, eq(householdMembers.userId, users.id))
|
||||
.where(eq(householdMembers.householdId, row.household.id))
|
||||
.limit(6);
|
||||
return { household: row.household, members };
|
||||
}
|
||||
|
||||
export async function Sidebar({ variant = "side" }: { variant?: Variant }) {
|
||||
const { modules } = getRegistry();
|
||||
const moduleNav = modules.flatMap((m) => (m.nav ? [m.nav] : []));
|
||||
|
||||
const navItems = [
|
||||
...PRIMARY_NAV,
|
||||
...moduleNav.map((n) => ({ href: n.href, label: n.label, icon: n.icon ?? "circle" })),
|
||||
{ href: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
const session = await auth();
|
||||
const householdInfo = session?.user?.id ? await getHouseholdInfo(session.user.id) : null;
|
||||
|
||||
if (variant === "top") {
|
||||
return (
|
||||
<header className="sidebar sidebar-top">
|
||||
<Link href="/" className="brand">
|
||||
<BrandMark />
|
||||
<span className="brand-name">famapp</span>
|
||||
</Link>
|
||||
{navItems.map((n) => (
|
||||
<NavLink
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
label={n.label}
|
||||
icon={n.icon}
|
||||
className="!w-auto !h-9"
|
||||
/>
|
||||
))}
|
||||
<div className="ml-auto" />
|
||||
{householdInfo && (
|
||||
<span className="badge">
|
||||
<NavIcon name="people" className="size-3" />
|
||||
{householdInfo.household.name}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<Link href="/" className="brand">
|
||||
<BrandMark />
|
||||
<span className="brand-name">famapp</span>
|
||||
</Link>
|
||||
{navItems.map((n) => (
|
||||
<NavLink key={n.href} href={n.href} label={n.label} icon={n.icon} />
|
||||
))}
|
||||
<div className="nav-divider" />
|
||||
{SECONDARY_NAV.map((n) => (
|
||||
<NavLink key={n.href} href={n.href} label={n.label} icon={n.icon} />
|
||||
))}
|
||||
{householdInfo && <HouseholdPill info={householdInfo} />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function HouseholdPill({
|
||||
info,
|
||||
}: {
|
||||
info: NonNullable<Awaited<ReturnType<typeof getHouseholdInfo>>>;
|
||||
}) {
|
||||
const visible = info.members.slice(0, 3);
|
||||
return (
|
||||
<div className="household-pill" title={info.household.name}>
|
||||
<div style={{ display: "flex" }}>
|
||||
{visible.map((m, i) => {
|
||||
const initial = (m.name ?? m.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
|
||||
return (
|
||||
<span
|
||||
key={m.id}
|
||||
className="avatar avatar-sm"
|
||||
style={{
|
||||
background: avatarColor(m.id),
|
||||
marginLeft: i > 0 ? -6 : 0,
|
||||
boxShadow: "0 0 0 1.5px var(--card)",
|
||||
}}
|
||||
title={m.name ?? m.email ?? undefined}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="household-text" style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: "var(--ink)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{info.household.name}
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: 11 }}>
|
||||
{info.members.length} {info.members.length === 1 ? "member" : "members"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stable color from id — uses the 5 functional accents in rotation.
|
||||
const ACCENT_HUES = [
|
||||
"var(--c-household)",
|
||||
"var(--c-private)",
|
||||
"var(--c-kids)",
|
||||
"var(--c-work)",
|
||||
"var(--c-bills)",
|
||||
];
|
||||
function avatarColor(id: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
|
||||
return ACCENT_HUES[hash % ACCENT_HUES.length] ?? "var(--c-household)";
|
||||
}
|
||||
Reference in New Issue
Block a user