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
-82
View File
@@ -1,82 +0,0 @@
import Link from "next/link";
import { Settings } from "lucide-react";
import { desc, eq } from "drizzle-orm";
import { getRegistry } from "@/modules/_core/registry";
import type { DashboardMeta } from "@/app/d/actions";
import { notifications } from "@/modules/_core/schema";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";
import { DashboardSwitcher } from "./dashboard-switcher";
import { DashboardTab } from "./dashboard-tab";
import { NotificationBell } from "./notification-bell";
async function getNotifications(userId: string) {
const rows = await db
.select()
.from(notifications)
.where(eq(notifications.userId, userId))
.orderBy(desc(notifications.createdAt))
.limit(20);
const unread = rows.filter((n) => !n.readAt).length;
return { rows, unread };
}
export async function AppNav({ dashboards = [] }: { dashboards?: DashboardMeta[] }) {
const { modules } = getRegistry();
const navItems = modules.flatMap((m) => (m.nav ? [m.nav] : []));
const session = await auth();
const userId = session?.user?.id;
const { rows: notifRows, unread } = userId
? await getNotifications(userId)
: { rows: [], unread: 0 };
return (
<header className="border-b">
<nav className="px-4 py-3 flex items-center gap-6">
<Link href="/" className="font-semibold text-sm shrink-0">
famapp
</Link>
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.label}
</Link>
))}
<div className="ml-auto flex items-center gap-3">
{userId && (
<NotificationBell
initialUnread={unread}
initialItems={notifRows.map((n) => ({
id: n.id,
title: n.title,
body: n.body,
url: n.url ?? null,
createdAt: n.createdAt,
}))}
/>
)}
<Link
href="/settings"
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Settings"
>
<Settings className="size-4" />
</Link>
</div>
</nav>
{dashboards.length > 0 && (
<div className="flex items-center gap-1 border-t px-4 overflow-x-auto">
{dashboards.map((d) => (
<DashboardTab key={d.id} slug={d.slug} name={d.name} />
))}
<DashboardSwitcher dashboards={dashboards} />
</div>
)}
</header>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { headers } from "next/headers";
import type { NavStyle } from "@/modules/_core/themes";
import type { DashboardMeta } from "@/app/d/actions";
import { Sidebar } from "@/components/sidebar";
import { Topbar } from "@/components/topbar";
import { BottomNav } from "@/components/bottom-nav";
import { Fab } from "@/components/fab";
import { NavModeProvider } from "@/components/nav-mode-provider";
const BARE_PREFIXES = ["/s/", "/login"];
interface Props {
signedIn: boolean;
navStyle: NavStyle;
dashboards: DashboardMeta[];
children: React.ReactNode;
}
export async function AppShell({ signedIn, navStyle, children }: Props) {
const h = await headers();
const pathname = h.get("x-pathname") ?? "";
const bare = BARE_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(p + "/") || pathname === p.replace(/\/$/, ""),
);
if (bare || !signedIn) {
// No shell — share viewer and signed-out pages render bare.
return <>{children}</>;
}
const useTopVariant = navStyle === "top-nav";
return (
<div className="app">
{useTopVariant ? <Sidebar variant="top" /> : <Sidebar variant="side" />}
<main className="main">
<Topbar />
<div className="scroll-area">{children}</div>
</main>
<BottomNav />
<Fab />
<NavModeProvider navStyle={navStyle} />
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { NavLink } from "@/components/nav-link";
const ITEMS: Array<{ href: string; label: string; icon: string }> = [
{ href: "/", label: "Dashboard", icon: "home" },
{ href: "/calendar", label: "Calendar", icon: "calendar" },
{ href: "/lists", label: "Lists", icon: "list" },
{ href: "/notes", label: "Notes", icon: "note" },
{ href: "/settings", label: "Settings", icon: "settings" },
];
export function BottomNav() {
return (
<nav className="bottom-nav" aria-label="Primary navigation">
{ITEMS.map((it) => (
<NavLink key={it.href} href={it.href} icon={it.icon} label={it.label} variant="bottom" />
))}
</nav>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { cn } from "@/lib/utils";
export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; className?: string }) {
return (
<span
className={cn("brand-mark", size === "sm" && "brand-mark-sm", className)}
aria-hidden="true"
>
f
</span>
);
}
export function BrandWordmark({ className }: { className?: string }) {
return (
<span className={cn("brand", className)}>
<BrandMark />
<span className="brand-name">famapp</span>
</span>
);
}
+54 -5
View File
@@ -7,8 +7,9 @@ import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { GridLayout } from "react-grid-layout";
import type { Layout } from "react-grid-layout";
import { GripVertical, Settings2, Trash2, RotateCcw, Plus } from "lucide-react";
import type { DashboardLayout, WidgetPlacement } from "@/lib/dashboard";
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
import { computePresetLayoutFromMetas } from "@/lib/dashboard";
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
import { saveDashboardLayout, resetDashboardLayout } from "@/app/d/actions";
import { Button } from "@/components/ui/button";
@@ -34,6 +35,7 @@ export function DashboardEditor({
const [pickerOpen, setPickerOpen] = useState(false);
const [configuringIndex, setConfiguringIndex] = useState<number | null>(null);
const [containerWidth, setContainerWidth] = useState(1200);
const [presetMenuOpen, setPresetMenuOpen] = useState(false);
useEffect(() => {
function measure() {
@@ -107,6 +109,13 @@ export function DashboardEditor({
setConfiguringIndex(null);
}
function applyPreset(preset: PresetId) {
const next = computePresetLayoutFromMetas(preset, widgetMetas);
setPlacements(next.widgets);
setIsDirty(true);
setPresetMenuOpen(false);
}
const gridItems: Layout = placements.map((p, i) => ({
i: placementKey(p, i),
x: p.x,
@@ -119,10 +128,50 @@ export function DashboardEditor({
}));
return (
<div className="p-4 sm:p-6">
<div>
<div className="mb-6 flex items-center justify-between gap-4 flex-wrap">
<h1 className="text-2xl font-bold">{dashboard.name}</h1>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="serif text-[26px] tracking-tight">{dashboard.name}</h1>
<div className="flex items-center gap-2 flex-wrap relative">
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setPresetMenuOpen((o) => !o)}
disabled={isPending}
>
<LayoutGrid className="size-4 mr-1" />
Preset
</Button>
{presetMenuOpen && (
<div
className="absolute right-0 top-9 z-50 min-w-[180px] rounded-md border-[0.5px] bg-card shadow-[var(--shadow-pop)]"
style={{ borderColor: "var(--hair-2)" }}
onMouseLeave={() => setPresetMenuOpen(false)}
>
<button
type="button"
onClick={() => applyPreset("classic")}
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--shade)]"
>
Classic main + side rail
</button>
<button
type="button"
onClick={() => applyPreset("split")}
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--shade)]"
>
Split two even columns
</button>
<button
type="button"
onClick={() => applyPreset("glance")}
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--shade)]"
>
Glance single column
</button>
</div>
)}
</div>
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
<RotateCcw className="size-4 mr-1" />
Reset
+1 -5
View File
@@ -7,11 +7,7 @@ export function EditDashboardButton() {
const pathname = usePathname();
return (
<button
type="button"
onClick={() => router.push(`${pathname}?edit=1`)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors"
>
<button type="button" onClick={() => router.push(`${pathname}?edit=1`)} className="btn btn-sm">
Edit dashboard
</button>
);
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useQuickAdd } from "@/components/quick-add-provider";
import { NavIcon } from "@/components/nav-icon";
export function Fab() {
const { openSheet } = useQuickAdd();
return (
<button type="button" className="fab" aria-label="Quick add" onClick={openSheet}>
<NavIcon name="plus" className="size-6" strokeWidth={2.4} />
</button>
);
}
+32 -17
View File
@@ -70,36 +70,51 @@ export function InstallPrompt() {
return (
<div
role="banner"
className="fixed bottom-0 left-0 right-0 z-50 flex items-start gap-3 border-t bg-background px-4 py-3 shadow-lg sm:bottom-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 sm:rounded-xl sm:border sm:px-5 sm:py-4 sm:shadow-xl"
className="fixed bottom-[80px] left-0 right-0 z-50 flex items-start gap-3 px-4 py-3 sm:bottom-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 sm:px-5 sm:py-4"
style={{
background: "var(--card)",
border: "0.5px solid var(--hair-2)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-pop)",
margin: "0 14px",
}}
>
{/* App icon */}
<div className="mt-0.5 h-10 w-10 shrink-0 overflow-hidden rounded-xl bg-[#4F46E5]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon-192.png" alt="" className="h-full w-full object-cover" />
<div
className="mt-0.5 size-10 shrink-0 overflow-hidden flex items-center justify-center"
style={{
borderRadius: 8,
background: "var(--ink)",
color: "var(--paper)",
fontFamily: "var(--serif)",
fontSize: 18,
fontWeight: 500,
}}
>
f
</div>
<div className="flex-1 text-sm">
<p className="font-semibold leading-snug">Add famapp to your home screen</p>
<div className="flex-1 text-[13.5px]">
<p className="font-medium leading-snug text-[var(--ink)] m-0">
Add famapp to your home screen
</p>
{prompt === "android" && (
<>
<p className="mt-0.5 text-muted-foreground">
<p className="mt-1 muted text-[12.5px] m-0">
Install for a faster, app-like experience.
</p>
<button
onClick={install}
className="mt-2 rounded-md bg-[#4F46E5] px-3 py-1.5 text-xs font-semibold text-white"
>
<button onClick={install} className="btn btn-sm btn-primary mt-2">
Install
</button>
</>
)}
{prompt === "ios" && (
<p className="mt-0.5 text-muted-foreground">
Tap <Share className="inline-block h-4 w-4 align-text-bottom" aria-label="Share" /> then{" "}
<p className="mt-1 muted text-[12.5px] m-0">
Tap <Share className="inline-block size-3.5 align-text-bottom" aria-label="Share" />{" "}
then{" "}
<strong className="font-medium">
<Plus className="inline-block h-3.5 w-3.5 align-text-bottom" />
<Plus className="inline-block size-3 align-text-bottom" />
&nbsp;Add to Home Screen
</strong>
.
@@ -110,9 +125,9 @@ export function InstallPrompt() {
<button
onClick={dismiss}
aria-label="Dismiss"
className="mt-0.5 shrink-0 rounded p-1 text-muted-foreground hover:bg-muted"
className="mt-0.5 shrink-0 rounded p-1 text-[var(--ink-mute)] hover:bg-[var(--shade)]"
>
<X className="h-4 w-4" />
<X className="size-4" />
</button>
</div>
);
+83
View File
@@ -0,0 +1,83 @@
import {
Bell,
Calendar,
CalendarDays,
CheckSquare,
ChevronDown,
ChevronLeft,
ChevronRight,
Circle,
Clock,
Eye,
FileText,
Filter,
Globe,
History,
Home,
Link as LinkIcon,
ListChecks,
Lock,
Mail,
Menu,
MoreHorizontal,
Pencil,
Phone,
Pin,
PinOff,
Plus,
Search,
Settings,
Share2,
ShoppingCart,
Sparkles,
Sun,
Trash2,
Users,
X,
} from "lucide-react";
import type { LucideProps } from "lucide-react";
const ICONS: Record<string, React.ComponentType<LucideProps>> = {
home: Home,
calendar: Calendar,
"calendar-days": CalendarDays,
list: ListChecks,
"check-square": CheckSquare,
"file-text": FileText,
note: FileText,
settings: Settings,
history: History,
link: LinkIcon,
people: Users,
users: Users,
plus: Plus,
search: Search,
bell: Bell,
pin: Pin,
"pin-off": PinOff,
cart: ShoppingCart,
"shopping-cart": ShoppingCart,
share: Share2,
lock: Lock,
eye: Eye,
clock: Clock,
sparkles: Sparkles,
pencil: Pencil,
trash: Trash2,
globe: Globe,
phone: Phone,
filter: Filter,
sun: Sun,
mail: Mail,
menu: Menu,
more: MoreHorizontal,
x: X,
"chevron-left": ChevronLeft,
"chevron-right": ChevronRight,
"chevron-down": ChevronDown,
};
export function NavIcon({ name, ...rest }: { name: string } & LucideProps) {
const Icon = ICONS[name] ?? Circle;
return <Icon strokeWidth={1.6} {...rest} />;
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { NavIcon } from "@/components/nav-icon";
import { cn } from "@/lib/utils";
function isActive(pathname: string, href: string): boolean {
// Strip query/fragment from href before matching.
const cleaned = href.split("#")[0]?.split("?")[0] ?? href;
if (cleaned === "/") return pathname === "/" || pathname.startsWith("/d/");
return pathname === cleaned || pathname.startsWith(cleaned + "/");
}
interface Props {
href: string;
icon: string;
label: string;
className?: string;
iconClassName?: string;
variant?: "sidebar" | "bottom";
}
export function NavLink({
href,
icon,
label,
className,
iconClassName,
variant = "sidebar",
}: Props) {
const pathname = usePathname() ?? "";
const active = isActive(pathname, href);
if (variant === "bottom") {
return (
<Link href={href} aria-current={active ? "page" : undefined} className={className}>
<NavIcon name={icon} className={cn("size-5", iconClassName)} />
<span>{label}</span>
</Link>
);
}
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={cn("nav-item", className)}
title={label}
>
<NavIcon name={icon} className={cn("size-4", iconClassName)} />
<span className="nav-label">{label}</span>
</Link>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
import type { NavStyle } from "@/modules/_core/themes";
import { navStyleToDataNav } from "@/modules/_core/themes";
const MOBILE_QUERY = "(max-width: 759px)";
/** Listens to the mobile breakpoint and overrides data-nav on <html>.
* The pre-paint script in layout.tsx already applies the correct value
* on first paint; this picks up subsequent resizes. Runs once and stays
* mounted as long as the shell is mounted. */
export function NavModeProvider({ navStyle }: { navStyle: NavStyle }) {
useEffect(() => {
const html = document.documentElement;
const desktopNav = navStyleToDataNav(navStyle);
const apply = () => {
const isMobile = window.matchMedia(MOBILE_QUERY).matches;
const next = isMobile ? (navStyle === "fab-only" ? "fab" : "bottom") : desktopNav;
html.setAttribute("data-nav", next);
};
apply();
const mq = window.matchMedia(MOBILE_QUERY);
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, [navStyle]);
return null;
}
-17
View File
@@ -1,17 +0,0 @@
"use client";
import { useQuickAdd } from "./quick-add-provider";
export function QuickAddFab() {
const { openSheet } = useQuickAdd();
return (
<button
aria-label="Quick add"
onClick={openSheet}
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md transition-opacity hover:opacity-90"
>
<span className="text-xl leading-none">+</span>
</button>
);
}
+92 -38
View File
@@ -2,7 +2,9 @@
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Sparkles, X } from "lucide-react";
import { useQuickAdd } from "./quick-add-provider";
import { NavIcon } from "./nav-icon";
import type { SerializedQuickAddItem } from "@/modules/_core";
function groupByModule(
@@ -18,6 +20,15 @@ function groupByModule(
return map;
}
const QUICK_ADD_ICON: Record<string, string> = {
"calendar-plus": "calendar",
"calendar-days": "calendar",
"shopping-cart": "cart",
"list-checks": "check-square",
"list-plus": "list",
"file-plus": "note",
};
export function QuickAddSheet() {
const { sheetOpen, closeSheet, actions } = useQuickAdd();
const router = useRouter();
@@ -43,64 +54,107 @@ export function QuickAddSheet() {
return (
<>
{/* Backdrop */}
<div
ref={backdropRef}
className="fixed inset-0 z-40 bg-black/40"
className="fixed inset-0 z-40"
style={{ background: "rgba(31,27,22,.32)", backdropFilter: "blur(2px)" }}
onClick={closeSheet}
aria-hidden="true"
/>
{/* Sheet panel — bottom on mobile, right-anchored popover on sm+ */}
<div
role="dialog"
aria-modal="true"
aria-label="Quick add"
className="fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl bg-background shadow-xl sm:bottom-auto sm:left-auto sm:right-6 sm:top-16 sm:w-72 sm:rounded-xl"
className="fixed bottom-0 left-0 right-0 z-50 sm:bottom-auto sm:left-1/2 sm:top-24 sm:-translate-x-1/2 sm:w-[480px] sm:max-w-[calc(100vw-32px)]"
style={{
background: "var(--paper)",
borderRadius: "18px 18px 0 0",
boxShadow: "0 -8px 32px rgba(31,27,22,.16)",
overflow: "hidden",
}}
>
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-sm font-semibold">Quick add</span>
<div
style={{
padding: "16px 18px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: "0.5px solid var(--hair)",
}}
>
<h2
className="serif"
style={{ fontSize: 18, fontWeight: 500, margin: 0, color: "var(--ink)" }}
>
Quick add
</h2>
<button
onClick={closeSheet}
aria-label="Close quick add"
className="rounded p-1 text-muted-foreground hover:bg-muted"
className="btn btn-icon btn-ghost btn-sm"
>
<X className="size-3.5" />
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2 sm:max-h-96">
{[...groups.entries()].map(([moduleId, group]) => (
<div key={moduleId} className="mb-2">
<p className="px-2 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group.name}
</p>
{group.items.map((action) => (
<button
key={action.id}
onClick={() => handleAction(action.url)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
>
<span className="text-base leading-none">{iconEmoji(action.icon)}</span>
{action.label}
</button>
))}
</div>
))}
<div style={{ padding: "14px 18px" }}>
<div
className="muted"
style={{
fontSize: 12,
marginBottom: 14,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<Sparkles className="size-3" />
<span>Pick what to add or use K to search.</span>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{[...groups.entries()].map(([moduleId, group], i) => (
<div key={moduleId} className="mb-2.5">
<div className="eyebrow mb-2" style={{ marginTop: i === 0 ? 0 : 8 }}>
{group.name}
</div>
<div className="grid grid-cols-2 gap-1.5">
{group.items.map((action) => (
<button
key={action.id}
onClick={() => handleAction(action.url)}
className="btn btn-sm justify-start"
>
<NavIcon
name={QUICK_ADD_ICON[action.icon ?? ""] ?? "plus"}
className="size-3.5"
/>
<span className="truncate">{action.label}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
<div
className="dialog-foot"
style={{
padding: "12px 18px",
display: "flex",
gap: 8,
justifyContent: "flex-end",
borderTop: "0.5px solid var(--hair)",
background: "var(--paper-2)",
}}
>
<button className="btn btn-sm btn-ghost" onClick={closeSheet}>
Close
</button>
</div>
</div>
</>
);
}
function iconEmoji(icon?: string): string {
const map: Record<string, string> = {
"calendar-plus": "📅",
"calendar-days": "🗓️",
"shopping-cart": "🛒",
"list-checks": "✅",
"list-plus": "📋",
"file-plus": "📝",
};
return icon ? (map[icon] ?? "") : "";
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { NavIcon } from "@/components/nav-icon";
import { cn } from "@/lib/utils";
const SECTIONS = [
{ id: "household", label: "Household", icon: "people" },
{ id: "sharing", label: "Sharing & links", icon: "link" },
{ id: "notifications", label: "Notifications", icon: "bell" },
{ id: "calendars", label: "Calendars & lists", icon: "calendar" },
{ id: "appearance", label: "Appearance", icon: "sun" },
{ id: "data", label: "Data & backups", icon: "history" },
] as const;
export type SectionId = (typeof SECTIONS)[number]["id"];
export function SettingsSidebar({ active }: { active: SectionId }) {
const router = useRouter();
const pathname = usePathname();
return (
<nav
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] shadow-[var(--shadow-1)] h-fit"
style={{ borderColor: "var(--hair)" }}
aria-label="Settings sections"
>
<div className="card-h">
<h3 className="serif text-[15px] m-0 font-medium">Settings</h3>
</div>
<div className="p-1.5">
{SECTIONS.map((s) => (
<button
key={s.id}
type="button"
className={cn("nav-item h-9")}
aria-current={active === s.id ? "page" : undefined}
onClick={() => {
const url = new URL(window.location.href);
url.searchParams.set("s", s.id);
router.replace(url.pathname + "?" + url.searchParams.toString() + url.hash, {
scroll: false,
});
}}
>
<NavIcon name={s.icon} className="size-4" />
<span className="nav-label">{s.label}</span>
</button>
))}
<div className="nav-divider" />
<Link href="/settings/household" className="nav-item h-9">
<NavIcon name="users" className="size-4" />
<span className="nav-label">Manage household</span>
</Link>
</div>
<SectionHashSync pathname={pathname} />
</nav>
);
}
// When the URL changes with a hash like #sharing (used by sidebar Share-links link),
// ensure the matching section opens.
function SectionHashSync({ pathname }: { pathname: string | null }) {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
if (typeof window === "undefined") return;
const hash = window.location.hash.slice(1);
if (!hash) return;
if (!SECTIONS.some((s) => s.id === hash)) return;
if (searchParams?.get("s") === hash) return;
const url = new URL(window.location.href);
url.searchParams.set("s", hash);
router.replace(url.pathname + "?" + url.searchParams.toString(), { scroll: false });
}, [pathname, router, searchParams]);
return null;
}
export function SettingsTabsMobile({ active }: { active: SectionId }) {
const router = useRouter();
return (
<div className="seg w-full overflow-x-auto" style={{ display: "flex" }}>
{SECTIONS.map((s) => (
<button
key={s.id}
type="button"
role="tab"
aria-selected={active === s.id}
onClick={() => {
const url = new URL(window.location.href);
url.searchParams.set("s", s.id);
router.replace(url.pathname + "?" + url.searchParams.toString(), { scroll: false });
}}
>
{s.label.split(" ")[0]}
</button>
))}
</div>
);
}
export function ResponsiveSidebar({ active }: { active: SectionId }) {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 759px)");
const apply = () => setIsMobile(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
if (isMobile) return <SettingsTabsMobile active={active} />;
return <SettingsSidebar active={active} />;
}
+43
View File
@@ -0,0 +1,43 @@
interface Props {
date: Date;
/** Optional time string e.g. "7:30 9:30 pm" */
time?: string;
}
export function MiniDayCard({ date, time }: Props) {
const month = date.toLocaleDateString(undefined, { weekday: "short", month: "short" });
const day = date.getDate();
return (
<div
className="rounded-[var(--r-md)] p-4"
style={{
background: "var(--card)",
border: "0.5px solid var(--hair)",
}}
>
<div
style={{
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--bad)",
textTransform: "uppercase",
letterSpacing: "0.08em",
}}
>
{month}
</div>
<div
style={{
fontFamily: "var(--serif)",
fontSize: 32,
lineHeight: 1,
color: "var(--ink)",
margin: "6px 0 4px",
}}
>
{day}
</div>
{time && <div style={{ fontSize: 13, color: "var(--ink-soft)" }}>{time}</div>}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
interface Props {
name: string;
address?: string;
}
/** A tiny location card with a CSS-only gridded background and a center pin.
* No real map for v1 — just visual context. */
export function MiniMapCard({ name, address }: Props) {
return (
<div
className="rounded-[var(--r-md)] p-4 relative overflow-hidden flex flex-col gap-1.5"
style={{
background: "var(--card)",
border: "0.5px solid var(--hair)",
backgroundImage: `
linear-gradient(rgba(31,27,22,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(31,27,22,.05) 1px, transparent 1px)
`,
backgroundSize: "14px 14px",
backgroundPosition: "16px 16px",
}}
>
<span
style={{
fontFamily: "var(--mono)",
fontSize: 10.5,
color: "var(--ink-mute)",
textTransform: "uppercase",
letterSpacing: "0.06em",
zIndex: 1,
}}
>
Location
</span>
<span
style={{
fontFamily: "var(--serif)",
fontSize: 16,
color: "var(--ink-2)",
zIndex: 1,
}}
>
{name}
</span>
{address && (
<span
style={{
fontSize: 12,
color: "var(--ink-soft)",
zIndex: 1,
}}
>
{address}
</span>
)}
<span
aria-hidden="true"
style={{
position: "absolute",
width: 12,
height: 12,
borderRadius: "50%",
background: "var(--accent)",
boxShadow: "0 0 0 4px color-mix(in oklab, var(--accent) 18%, transparent)",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
/>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
interface Props {
expiresAt: Date | null;
capabilities: { read: boolean; write: boolean };
token: string;
}
function relativeExpiry(d: Date): string {
const ms = d.getTime() - Date.now();
if (ms <= 0) return "expired";
const days = Math.round(ms / 86400000);
if (days >= 1) return `in ${days} day${days === 1 ? "" : "s"}`;
const hours = Math.round(ms / 3600000);
if (hours >= 1) return `in ${hours} hour${hours === 1 ? "" : "s"}`;
const mins = Math.round(ms / 60000);
return `in ${mins} min`;
}
export function ShareBanner({ expiresAt, capabilities, token }: Props) {
const mode = capabilities.write ? "edit" : "view-only";
return (
<div
className="flex items-center gap-3 px-6 py-2.5 text-[12.5px] flex-wrap"
style={{
background: "var(--paper-2)",
borderBottom: "0.5px solid var(--hair)",
color: "var(--ink-soft)",
}}
>
<span className="dot" style={{ background: "var(--c-bills)", width: 6, height: 6 }} />
<span>
Public share link · <span style={{ textTransform: "lowercase" }}>{mode}</span>
</span>
{expiresAt ? (
<>
<span style={{ color: "var(--ink-2)", fontWeight: 600 }}>
· expires {relativeExpiry(expiresAt)}
</span>
<span>· revoke anytime</span>
</>
) : (
<span>· no expiration</span>
)}
<code
className="ml-auto"
style={{
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--ink-mute)",
background: "var(--card)",
padding: "3px 8px",
borderRadius: 4,
border: "0.5px solid var(--hair)",
}}
>
/s/{token.slice(0, 12)}
</code>
</div>
);
}
@@ -0,0 +1,29 @@
import { BrandMark } from "@/components/brand-mark";
export function ShareBrandStrip({ sharedByName }: { sharedByName?: string | null }) {
return (
<div
className="px-6 py-7"
style={{ borderBottom: "0.5px solid var(--hair)", background: "var(--card)" }}
>
<div className="flex items-center gap-2.5 max-w-[720px] mx-auto">
<BrandMark />
<span
style={{
fontFamily: "var(--serif)",
fontSize: 15,
color: "var(--ink-2)",
fontWeight: 500,
}}
>
famapp{" "}
{sharedByName && (
<span style={{ color: "var(--ink-mute)", fontWeight: 400 }}>
· shared by {sharedByName}
</span>
)}
</span>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
export function ShareDetailCard({ children }: { children: ReactNode }) {
return (
<div
className="rounded-[var(--r-md)] mb-3.5"
style={{
background: "var(--card)",
border: "0.5px solid var(--hair)",
padding: "18px 22px",
}}
>
{children}
</div>
);
}
export function ShareRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div
className="grid items-baseline gap-4 py-2.5"
style={{
gridTemplateColumns: "110px 1fr",
borderBottom: "0.5px solid var(--hair)",
fontSize: "14.5px",
}}
>
<span
style={{
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--ink-mute)",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
{label}
</span>
<span style={{ color: "var(--ink-2)" }}>{children}</span>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
export function ShareEyebrow({ children }: { children: ReactNode }) {
return (
<span
className="inline-flex items-center gap-2 mb-5"
style={{
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--ink-mute)",
textTransform: "uppercase",
letterSpacing: "0.06em",
padding: "4px 10px",
borderRadius: 999,
background: "var(--paper-2)",
}}
>
{children}
</span>
);
}
+46
View File
@@ -0,0 +1,46 @@
import Link from "next/link";
import { ShareBanner } from "./share-banner";
import { ShareBrandStrip } from "./share-brand-strip";
interface Props {
expiresAt?: Date | null;
capabilities: { read: boolean; write: boolean };
token: string;
sharedByName?: string | null;
children: React.ReactNode;
}
export function ShareFrame({ expiresAt, capabilities, token, sharedByName, children }: Props) {
return (
<div className="min-h-screen flex flex-col">
<ShareBanner expiresAt={expiresAt ?? null} capabilities={capabilities} token={token} />
<ShareBrandStrip sharedByName={sharedByName} />
<main className="flex-1 w-full mx-auto" style={{ maxWidth: 720, padding: "36px 24px 80px" }}>
{children}
<ShareFoot />
</main>
</div>
);
}
function ShareFoot() {
return (
<div className="muted text-center text-[12px] mt-16 leading-[1.6]">
<p className="mb-1">
This page is a snapshot. The original lives in the host&apos;s famapp household and may
change &mdash; we&apos;ll reflect updates here until the link expires.
</p>
<p className="m-0">
Hosted on <code style={{ fontFamily: "var(--mono)", fontSize: 11 }}>famapp</code> ·
self-hosted ·{" "}
<Link
href="/"
className="underline"
style={{ color: "var(--ink-soft)", textUnderlineOffset: 3 }}
>
about famapp
</Link>
</p>
</div>
);
}
+155
View File
@@ -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)";
}
+152 -21
View File
@@ -1,7 +1,23 @@
"use client";
import { THEMES, THEME_MODES } from "@/modules/_core/themes";
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
import {
PALETTES,
THEME_MODES,
FONT_PAIRS,
DENSITIES,
DASH_LAYOUTS,
CAL_VIEWS,
NAV_STYLES,
} from "@/modules/_core/themes";
import type {
Palette,
ThemeMode,
FontPair,
Density,
DashLayout,
CalView,
NavStyle,
} from "@/modules/_core/themes";
import { useTheme } from "@/hooks/use-theme";
import { Label } from "@/components/ui/label";
import {
@@ -15,34 +31,64 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface Props {
initialTheme?: ThemeId;
initialPalette?: Palette;
initialMode?: ThemeMode;
initialFontPair?: FontPair;
initialDensity?: Density;
initialDashLayout?: DashLayout;
initialCalView?: CalView;
initialNavStyle?: NavStyle;
signedIn?: boolean;
}
export function ThemePicker({
initialTheme = "default",
initialPalette = "clay",
initialMode = "system",
initialFontPair = "serif-sans",
initialDensity = "regular",
initialDashLayout = "classic",
initialCalView = "month",
initialNavStyle = "rail-desktop",
signedIn = false,
}: Props) {
const { theme, mode, setTheme, setMode } = useTheme(initialTheme, initialMode, signedIn);
const t = useTheme(
{
palette: initialPalette,
mode: initialMode,
fontPair: initialFontPair,
density: initialDensity,
dashLayout: initialDashLayout,
calView: initialCalView,
navStyle: initialNavStyle,
},
signedIn,
);
return (
<div className="space-y-4">
<div className="space-y-5">
<div className="space-y-1.5">
<Label>Theme</Label>
<Select value={theme} onValueChange={(v) => setTheme(v as ThemeId)}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THEMES.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Label>Palette</Label>
<div className="flex gap-2">
{PALETTES.map((p) => (
<button
key={p.id}
type="button"
onClick={() => t.setPalette(p.id)}
aria-pressed={t.palette === p.id}
title={p.label}
className={cn(
"size-7 rounded-md border-[0.5px] cursor-pointer transition-transform",
t.palette === p.id ? "scale-110" : "hover:scale-105",
)}
style={{
background: p.hex,
borderColor: "var(--hair-2)",
outline: t.palette === p.id ? "2px solid var(--ink)" : "none",
outlineOffset: 2,
}}
/>
))}
</div>
</div>
<div className="space-y-1.5">
@@ -53,14 +99,99 @@ export function ThemePicker({
key={m.id}
variant="outline"
size="sm"
className={cn(mode === m.id && "bg-primary text-primary-foreground")}
onClick={() => setMode(m.id)}
className={cn(t.mode === m.id && "bg-primary text-primary-foreground")}
onClick={() => t.setMode(m.id)}
>
{m.label}
</Button>
))}
</div>
</div>
<div className="space-y-1.5">
<Label>Type pairing</Label>
<Select value={t.fontPair} onValueChange={(v) => t.setFontPair(v as FontPair)}>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_PAIRS.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Density</Label>
<div className="flex gap-1">
{DENSITIES.map((d) => (
<Button
key={d.id}
variant="outline"
size="sm"
className={cn(t.density === d.id && "bg-primary text-primary-foreground")}
onClick={() => t.setDensity(d.id)}
>
{d.label}
</Button>
))}
</div>
</div>
<div className="space-y-1.5">
<Label>Dashboard layout</Label>
<Select value={t.dashLayout} onValueChange={(v) => t.setDashLayout(v as DashLayout)}>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DASH_LAYOUTS.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Calendar default</Label>
<div className="flex gap-1">
{CAL_VIEWS.map((c) => (
<Button
key={c.id}
variant="outline"
size="sm"
className={cn(t.calView === c.id && "bg-primary text-primary-foreground")}
onClick={() => t.setCalView(c.id)}
>
{c.label}
</Button>
))}
</div>
</div>
<div className="space-y-1.5">
<Label>Navigation</Label>
<Select value={t.navStyle} onValueChange={(v) => t.setNavStyle(v as NavStyle)}>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NAV_STYLES.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Mobile (under 760px) always uses bottom nav + FAB.
</p>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useQuickAdd } from "@/components/quick-add-provider";
import { NavIcon } from "@/components/nav-icon";
export function TopbarNewButton() {
const { openSheet } = useQuickAdd();
return (
<button
type="button"
onClick={openSheet}
className="btn btn-primary btn-sm"
aria-label="Quick add"
>
<NavIcon name="plus" className="size-3.5" />
<span className="hidden md:inline">New</span>
</button>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useQuickAdd } from "@/components/quick-add-provider";
import { NavIcon } from "@/components/nav-icon";
export function TopbarSearch() {
const { openPalette } = useQuickAdd();
return (
<button
type="button"
onClick={openPalette}
aria-label="Search (⌘K)"
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-md text-[13px]"
style={{
border: "0.5px solid var(--hair-2)",
background: "var(--card)",
color: "var(--ink-mute)",
width: 220,
}}
>
<NavIcon name="search" className="size-3.5" />
<span style={{ flex: 1, textAlign: "left" }}>Search</span>
<span className="kbd">K</span>
</button>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { usePathname } from "next/navigation";
const TITLES: Array<{ test: (p: string) => boolean; title: string; sub?: string }> = [
{ test: (p) => p === "/" || p.startsWith("/d/"), title: "Today", sub: "Dashboard" },
{
test: (p) => p === "/calendar" || p.startsWith("/calendar/"),
title: "Calendar",
sub: "household + private",
},
{ test: (p) => p === "/lists" || p.startsWith("/lists/"), title: "Lists" },
{ test: (p) => p === "/notes" || p.startsWith("/notes/"), title: "Notes" },
{ test: (p) => p === "/settings" || p.startsWith("/settings/"), title: "Settings" },
{ test: (p) => p === "/login", title: "Sign in" },
];
export function TopbarTitle() {
const pathname = usePathname() ?? "";
const match = TITLES.find((t) => t.test(pathname)) ?? { title: "famapp" };
return (
<div style={{ minWidth: 0 }}>
<h1
className="serif"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{match.title}
</h1>
{"sub" in match && match.sub && <div className="crumb">{match.sub}</div>}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { desc, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { notifications, users } from "@/modules/_core/schema";
import { NotificationBell } from "@/components/notification-bell";
import { TopbarSearch } from "@/components/topbar-search";
import { TopbarTitle } from "@/components/topbar-title";
import { TopbarNewButton } from "@/components/topbar-new-button";
import { NavIcon } from "@/components/nav-icon";
async function getNotifications(userId: string) {
const rows = await db
.select()
.from(notifications)
.where(eq(notifications.userId, userId))
.orderBy(desc(notifications.createdAt))
.limit(20);
const unread = rows.filter((n) => !n.readAt).length;
return { rows, unread };
}
async function getUserAvatar(userId: string) {
const [row] = await db
.select({ name: users.name, email: users.email, image: users.image })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return row;
}
export async function Topbar() {
const session = await auth();
const userId = session?.user?.id;
const { rows: notifRows, unread } = userId
? await getNotifications(userId)
: { rows: [], unread: 0 };
const userRow = userId ? await getUserAvatar(userId) : null;
const initial = (userRow?.name ?? userRow?.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
return (
<div className="topbar">
<TopbarTitle />
<div className="topbar-actions">
<TopbarSearch />
{userId && (
<NotificationBell
initialUnread={unread}
initialItems={notifRows.map((n) => ({
id: n.id,
title: n.title,
body: n.body,
url: n.url ?? null,
createdAt: n.createdAt,
}))}
/>
)}
<TopbarNewButton />
{userId && (
<span
className="avatar"
style={{ width: 28, height: 28, fontSize: 12, background: "var(--c-household)" }}
title={userRow?.name ?? userRow?.email ?? undefined}
>
{userRow?.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={userRow.image}
alt=""
style={{ width: "100%", height: "100%", borderRadius: "50%", objectFit: "cover" }}
/>
) : (
initial
)}
</span>
)}
</div>
</div>
);
}
export function TopbarSpacer() {
return (
<div className="topbar">
<h1 className="serif">famapp</h1>
<div className="topbar-actions">
<NavIcon name="bell" className="size-4 text-[var(--ink-mute)]" />
</div>
</div>
);
}
+11 -14
View File
@@ -12,7 +12,8 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
"group/card flex flex-col overflow-hidden rounded-[var(--r-lg)] bg-card text-sm text-card-foreground",
"border-[0.5px] border-[var(--hair)] shadow-[var(--shadow-1)]",
className,
)}
{...props}
@@ -25,7 +26,9 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
"group/card-header flex items-center justify-between gap-2",
"px-[14px] pt-3 pb-[10px] border-b-[0.5px] border-[var(--hair)]",
"has-data-[slot=card-description]:flex-col has-data-[slot=card-description]:items-start has-data-[slot=card-description]:gap-1",
className,
)}
{...props}
@@ -35,10 +38,10 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
<h3
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
"font-[family-name:var(--serif)] text-[16px] font-medium tracking-tight m-0 text-[var(--ink)]",
className,
)}
{...props}
@@ -50,7 +53,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-xs text-muted-foreground", className)}
{...props}
/>
);
@@ -60,20 +63,14 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
className={cn("ml-auto flex items-center gap-2 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
);
return <div data-slot="card-content" className={cn("px-[14px] py-3", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -81,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
"flex items-center justify-end gap-2 px-[14px] py-3 border-t-[0.5px] border-[var(--hair)] bg-[var(--paper-2)]",
className,
)}
{...props}