-
Add famapp to your home screen
+
+
+ Add famapp to your home screen
+
{prompt === "android" && (
<>
-
+
Install for a faster, app-like experience.
-
);
diff --git a/src/components/nav-icon.tsx b/src/components/nav-icon.tsx
new file mode 100644
index 0000000..07489c7
--- /dev/null
+++ b/src/components/nav-icon.tsx
@@ -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
> = {
+ 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 ;
+}
diff --git a/src/components/nav-link.tsx b/src/components/nav-link.tsx
new file mode 100644
index 0000000..66bff60
--- /dev/null
+++ b/src/components/nav-link.tsx
@@ -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 (
+
+
+ {label}
+
+ );
+ }
+
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/src/components/nav-mode-provider.tsx b/src/components/nav-mode-provider.tsx
new file mode 100644
index 0000000..61a35da
--- /dev/null
+++ b/src/components/nav-mode-provider.tsx
@@ -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 .
+ * 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;
+}
diff --git a/src/components/quick-add-fab.tsx b/src/components/quick-add-fab.tsx
deleted file mode 100644
index 6ffa04e..0000000
--- a/src/components/quick-add-fab.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-"use client";
-
-import { useQuickAdd } from "./quick-add-provider";
-
-export function QuickAddFab() {
- const { openSheet } = useQuickAdd();
-
- return (
-
- +
-
- );
-}
diff --git a/src/components/quick-add-sheet.tsx b/src/components/quick-add-sheet.tsx
index 46268d4..0a1fe0d 100644
--- a/src/components/quick-add-sheet.tsx
+++ b/src/components/quick-add-sheet.tsx
@@ -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 = {
+ "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 */}
- {/* Sheet panel — bottom on mobile, right-anchored popover on sm+ */}
-
-
Quick add
+
+
+ Quick add
+
- ✕
+
-
- {[...groups.entries()].map(([moduleId, group]) => (
-
-
- {group.name}
-
- {group.items.map((action) => (
-
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"
- >
- {iconEmoji(action.icon)}
- {action.label}
-
- ))}
-
- ))}
+
+
+
+ Pick what to add — or use ⌘K to search.
+
+
+
+ {[...groups.entries()].map(([moduleId, group], i) => (
+
+
+ {group.name}
+
+
+ {group.items.map((action) => (
+ handleAction(action.url)}
+ className="btn btn-sm justify-start"
+ >
+
+ {action.label}
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+ Close
+
>
);
}
-
-function iconEmoji(icon?: string): string {
- const map: Record
= {
- "calendar-plus": "📅",
- "calendar-days": "🗓️",
- "shopping-cart": "🛒",
- "list-checks": "✅",
- "list-plus": "📋",
- "file-plus": "📝",
- };
- return icon ? (map[icon] ?? "➕") : "➕";
-}
diff --git a/src/components/settings-section.tsx b/src/components/settings-section.tsx
new file mode 100644
index 0000000..7f4b4b3
--- /dev/null
+++ b/src/components/settings-section.tsx
@@ -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 (
+
+ );
+}
+
+// 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 (
+
+ {SECTIONS.map((s) => (
+ {
+ 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]}
+
+ ))}
+
+ );
+}
+
+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 ;
+ return ;
+}
diff --git a/src/components/share/mini-day-card.tsx b/src/components/share/mini-day-card.tsx
new file mode 100644
index 0000000..e51f4de
--- /dev/null
+++ b/src/components/share/mini-day-card.tsx
@@ -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 (
+
+
+ {month}
+
+
+ {day}
+
+ {time &&
{time}
}
+
+ );
+}
diff --git a/src/components/share/mini-map-card.tsx b/src/components/share/mini-map-card.tsx
new file mode 100644
index 0000000..d8c6e7d
--- /dev/null
+++ b/src/components/share/mini-map-card.tsx
@@ -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 (
+
+
+ Location
+
+
+ {name}
+
+ {address && (
+
+ {address}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/share/share-banner.tsx b/src/components/share/share-banner.tsx
new file mode 100644
index 0000000..903c880
--- /dev/null
+++ b/src/components/share/share-banner.tsx
@@ -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 (
+
+
+
+ Public share link · {mode}
+
+ {expiresAt ? (
+ <>
+
+ · expires {relativeExpiry(expiresAt)}
+
+ · revoke anytime
+ >
+ ) : (
+ · no expiration
+ )}
+
+ /s/{token.slice(0, 12)}
+
+
+ );
+}
diff --git a/src/components/share/share-brand-strip.tsx b/src/components/share/share-brand-strip.tsx
new file mode 100644
index 0000000..9e1d2f2
--- /dev/null
+++ b/src/components/share/share-brand-strip.tsx
@@ -0,0 +1,29 @@
+import { BrandMark } from "@/components/brand-mark";
+
+export function ShareBrandStrip({ sharedByName }: { sharedByName?: string | null }) {
+ return (
+
+
+
+
+ famapp{" "}
+ {sharedByName && (
+
+ · shared by {sharedByName}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/share/share-detail-card.tsx b/src/components/share/share-detail-card.tsx
new file mode 100644
index 0000000..af86710
--- /dev/null
+++ b/src/components/share/share-detail-card.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from "react";
+
+export function ShareDetailCard({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function ShareRow({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
diff --git a/src/components/share/share-eyebrow.tsx b/src/components/share/share-eyebrow.tsx
new file mode 100644
index 0000000..ec68f3c
--- /dev/null
+++ b/src/components/share/share-eyebrow.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from "react";
+
+export function ShareEyebrow({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/share/share-frame.tsx b/src/components/share/share-frame.tsx
new file mode 100644
index 0000000..e80acf2
--- /dev/null
+++ b/src/components/share/share-frame.tsx
@@ -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 (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function ShareFoot() {
+ return (
+
+
+ This page is a snapshot. The original lives in the host's famapp household and may
+ change — we'll reflect updates here until the link expires.
+
+
+ Hosted on famapp ·
+ self-hosted ·{" "}
+
+ about famapp
+
+
+
+ );
+}
diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx
new file mode 100644
index 0000000..78c19d6
--- /dev/null
+++ b/src/components/sidebar.tsx
@@ -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 (
+
+
+
+ famapp
+
+ {navItems.map((n) => (
+
+ ))}
+
+ {householdInfo && (
+
+
+ {householdInfo.household.name}
+
+ )}
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function HouseholdPill({
+ info,
+}: {
+ info: NonNullable>>;
+}) {
+ const visible = info.members.slice(0, 3);
+ return (
+
+
+ {visible.map((m, i) => {
+ const initial = (m.name ?? m.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
+ return (
+ 0 ? -6 : 0,
+ boxShadow: "0 0 0 1.5px var(--card)",
+ }}
+ title={m.name ?? m.email ?? undefined}
+ >
+ {initial}
+
+ );
+ })}
+
+
+
+ {info.household.name}
+
+
+ {info.members.length} {info.members.length === 1 ? "member" : "members"}
+
+
+
+ );
+}
+
+// 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)";
+}
diff --git a/src/components/theme-picker.tsx b/src/components/theme-picker.tsx
index 5b21727..eda6f39 100644
--- a/src/components/theme-picker.tsx
+++ b/src/components/theme-picker.tsx
@@ -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 (
-
+
-
-
+
+
+ {PALETTES.map((p) => (
+ 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,
+ }}
+ />
+ ))}
+
@@ -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}
))}
+
+
+
+
+
+
+
+
+
+ {DENSITIES.map((d) => (
+ t.setDensity(d.id)}
+ >
+ {d.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {CAL_VIEWS.map((c) => (
+ t.setCalView(c.id)}
+ >
+ {c.label}
+
+ ))}
+
+
+
+
+
+
+
+ Mobile (under 760px) always uses bottom nav + FAB.
+
+
);
}
diff --git a/src/components/topbar-new-button.tsx b/src/components/topbar-new-button.tsx
new file mode 100644
index 0000000..5ead29f
--- /dev/null
+++ b/src/components/topbar-new-button.tsx
@@ -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 (
+
+
+ New
+
+ );
+}
diff --git a/src/components/topbar-search.tsx b/src/components/topbar-search.tsx
new file mode 100644
index 0000000..1a53290
--- /dev/null
+++ b/src/components/topbar-search.tsx
@@ -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 (
+
+
+ Search…
+ ⌘K
+
+ );
+}
diff --git a/src/components/topbar-title.tsx b/src/components/topbar-title.tsx
new file mode 100644
index 0000000..ec388a9
--- /dev/null
+++ b/src/components/topbar-title.tsx
@@ -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 (
+
+
+ {match.title}
+
+ {"sub" in match && match.sub &&
{match.sub}
}
+
+ );
+}
diff --git a/src/components/topbar.tsx b/src/components/topbar.tsx
new file mode 100644
index 0000000..ffd4903
--- /dev/null
+++ b/src/components/topbar.tsx
@@ -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 (
+
+
+
+
+ {userId && (
+
({
+ id: n.id,
+ title: n.title,
+ body: n.body,
+ url: n.url ?? null,
+ createdAt: n.createdAt,
+ }))}
+ />
+ )}
+
+ {userId && (
+
+ {userRow?.image ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+ initial
+ )}
+
+ )}
+
+
+ );
+}
+
+export function TopbarSpacer() {
+ return (
+
+ );
+}
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
index e6cb382..727f215 100644
--- a/src/components/ui/card.tsx
+++ b/src/components/ui/card.tsx
@@ -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">) {
) {
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
-
) {
return (
);
@@ -60,20 +63,14 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
+ return
;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -81,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
(initialTheme);
- const [mode, setModeState] = useState
(initialMode);
+ const [palette, setPaletteState] = useState(initial.palette ?? "clay");
+ const [mode, setModeState] = useState(initial.mode ?? "system");
+ const [fontPair, setFontPairState] = useState(initial.fontPair ?? "serif-sans");
+ const [density, setDensityState] = useState(initial.density ?? "regular");
+ const [dashLayout, setDashLayoutState] = useState(initial.dashLayout ?? "classic");
+ const [calView, setCalViewState] = useState(initial.calView ?? "month");
+ const [navStyle, setNavStyleState] = useState(initial.navStyle ?? "rail-desktop");
- const setTheme = useCallback(
- (next: ThemeId) => {
- setThemeState(next);
- applyTheme(next, mode);
- if (signedIn) void setUserTheme({ theme: next, mode });
+ // Re-apply data-nav whenever the viewport crosses the mobile breakpoint.
+ useEffect(() => {
+ const mq = window.matchMedia(MOBILE_QUERY);
+ const onChange = () => applyTheme({ palette, mode, fontPair, density, navStyle });
+ mq.addEventListener("change", onChange);
+ return () => mq.removeEventListener("change", onChange);
+ }, [palette, mode, fontPair, density, navStyle]);
+
+ // Re-apply dark when system pref flips and we're in 'system' mode.
+ useEffect(() => {
+ if (mode !== "system") return;
+ const mq = window.matchMedia(DARK_QUERY);
+ const onChange = () => applyTheme({ palette, mode, fontPair, density, navStyle });
+ mq.addEventListener("change", onChange);
+ return () => mq.removeEventListener("change", onChange);
+ }, [palette, mode, fontPair, density, navStyle]);
+
+ const persist = useCallback(
+ (patch: Parameters[0]) => {
+ if (signedIn) void setUserTheme(patch);
},
- [mode, signedIn],
+ [signedIn],
+ );
+
+ const setPalette = useCallback(
+ (next: Palette) => {
+ setPaletteState(next);
+ applyTheme({ palette: next, mode, fontPair, density, navStyle });
+ persist({ palette: next });
+ },
+ [mode, fontPair, density, navStyle, persist],
);
const setMode = useCallback(
(next: ThemeMode) => {
setModeState(next);
- applyTheme(theme, next);
- if (signedIn) void setUserTheme({ theme, mode: next });
+ applyTheme({ palette, mode: next, fontPair, density, navStyle });
+ persist({ mode: next });
},
- [theme, signedIn],
+ [palette, fontPair, density, navStyle, persist],
);
- return { theme, mode, setTheme, setMode };
+ const setFontPair = useCallback(
+ (next: FontPair) => {
+ setFontPairState(next);
+ applyTheme({ palette, mode, fontPair: next, density, navStyle });
+ persist({ fontPair: next });
+ },
+ [palette, mode, density, navStyle, persist],
+ );
+
+ const setDensity = useCallback(
+ (next: Density) => {
+ setDensityState(next);
+ applyTheme({ palette, mode, fontPair, density: next, navStyle });
+ persist({ density: next });
+ },
+ [palette, mode, fontPair, navStyle, persist],
+ );
+
+ const setDashLayout = useCallback(
+ (next: DashLayout) => {
+ setDashLayoutState(next);
+ try {
+ localStorage.setItem("themeDashLayout", next);
+ } catch {
+ // storage blocked
+ }
+ persist({ dashLayout: next });
+ },
+ [persist],
+ );
+
+ const setCalView = useCallback(
+ (next: CalView) => {
+ setCalViewState(next);
+ try {
+ localStorage.setItem("themeCalView", next);
+ } catch {
+ // storage blocked
+ }
+ persist({ calView: next });
+ },
+ [persist],
+ );
+
+ const setNavStyle = useCallback(
+ (next: NavStyle) => {
+ setNavStyleState(next);
+ applyTheme({ palette, mode, fontPair, density, navStyle: next });
+ persist({ navStyle: next });
+ },
+ [palette, mode, fontPair, density, persist],
+ );
+
+ return {
+ palette,
+ mode,
+ fontPair,
+ density,
+ dashLayout,
+ calView,
+ navStyle,
+ setPalette,
+ setMode,
+ setFontPair,
+ setDensity,
+ setDashLayout,
+ setCalView,
+ setNavStyle,
+ };
}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 329fb54..fd7233e 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,12 +1,7 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import NextAuth, { type DefaultSession } from "next-auth";
import { db } from "@/lib/db";
-import {
- accounts,
- sessions,
- users,
- verificationTokens,
-} from "@/modules/_core/schema";
+import { accounts, sessions, users, verificationTokens } from "@/modules/_core/schema";
declare module "next-auth" {
interface Session {
diff --git a/src/lib/dashboard.server.ts b/src/lib/dashboard.server.ts
new file mode 100644
index 0000000..7017c38
--- /dev/null
+++ b/src/lib/dashboard.server.ts
@@ -0,0 +1,31 @@
+import "server-only";
+import { getRegistry } from "@/modules/_core";
+import type { SerializedWidgetMeta } from "@/modules/_core/registry";
+import type { DashboardLayout, PresetId } from "./dashboard";
+import { computePresetLayoutFromMetas } from "./dashboard";
+
+/** Build a layout matching one of the design's three dashboard arrangements
+ * using the live module registry. Server-only — the registry is empty on
+ * the client. */
+export function computePresetLayout(preset: PresetId): DashboardLayout {
+ const { widgets } = getRegistry();
+ if (widgets.length === 0) return { version: 1, widgets: [] };
+
+ const sorted = [...widgets].sort((a, b) => a.defaultPriority - b.defaultPriority);
+ const metas: SerializedWidgetMeta[] = sorted.map((w) => ({
+ id: w.id,
+ title: w.title,
+ description: w.description,
+ category: w.category,
+ defaultSize: w.defaultSize,
+ minSize: w.minSize,
+ maxSize: w.maxSize,
+ defaultConfig: w.defaultConfig,
+ }));
+
+ return computePresetLayoutFromMetas(preset, metas);
+}
+
+export function computeDefaultLayout(): DashboardLayout {
+ return computePresetLayout("classic");
+}
diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts
index 774bc3a..371faba 100644
--- a/src/lib/dashboard.ts
+++ b/src/lib/dashboard.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { getRegistry } from "@/modules/_core";
+import type { SerializedWidgetMeta } from "@/modules/_core/registry";
export type WidgetPlacement = {
widgetId: string;
@@ -35,33 +35,104 @@ export function parseDashboardLayout(raw: unknown): DashboardLayout | null {
return result.data;
}
-export function computeDefaultLayout(): DashboardLayout {
- const { widgets } = getRegistry();
- const sorted = [...widgets].sort((a, b) => a.defaultPriority - b.defaultPriority);
+export type PresetId = "classic" | "split" | "glance";
- const placements: WidgetPlacement[] = [];
- let curX = 0;
- let curY = 0;
- let rowH = 0;
-
- for (const widget of sorted) {
- const { w, h } = widget.defaultSize;
- if (curX + w > 12) {
- curY += rowH;
- curX = 0;
- rowH = 0;
- }
- placements.push({
- widgetId: widget.id,
- config: widget.defaultConfig,
- x: curX,
- y: curY,
- w,
- h,
- });
- curX += w;
- rowH = Math.max(rowH, h);
+/** Client-safe layout builder: pass the metas explicitly. The server-only
+ * variants (`computePresetLayout`, `computeDefaultLayout`) live in
+ * `dashboard.server.ts` because they reach into the module registry. */
+export function computePresetLayoutFromMetas(
+ preset: PresetId,
+ metas: SerializedWidgetMeta[],
+): DashboardLayout {
+ switch (preset) {
+ case "classic":
+ return classicLayout(metas);
+ case "split":
+ return splitLayout(metas);
+ case "glance":
+ return glanceLayout(metas);
}
+}
+
+function classicLayout(metas: SerializedWidgetMeta[]): DashboardLayout {
+ const placements: WidgetPlacement[] = [];
+ let mainY = 0;
+ let railY = 0;
+
+ metas.forEach((m, i) => {
+ const inRail = i % 3 === 0 && i > 0;
+ if (inRail) {
+ placements.push({
+ widgetId: m.id,
+ config: m.defaultConfig,
+ x: 8,
+ y: railY,
+ w: 4,
+ h: m.defaultSize.h,
+ });
+ railY += m.defaultSize.h;
+ } else {
+ placements.push({
+ widgetId: m.id,
+ config: m.defaultConfig,
+ x: 0,
+ y: mainY,
+ w: 8,
+ h: m.defaultSize.h,
+ });
+ mainY += m.defaultSize.h;
+ }
+ });
return { version: 1, widgets: placements };
}
+
+function splitLayout(metas: SerializedWidgetMeta[]): DashboardLayout {
+ const placements: WidgetPlacement[] = [];
+ let leftY = 0;
+ let rightY = 0;
+
+ metas.forEach((m, i) => {
+ const left = i % 2 === 0;
+ if (left) {
+ placements.push({
+ widgetId: m.id,
+ config: m.defaultConfig,
+ x: 0,
+ y: leftY,
+ w: 6,
+ h: m.defaultSize.h,
+ });
+ leftY += m.defaultSize.h;
+ } else {
+ placements.push({
+ widgetId: m.id,
+ config: m.defaultConfig,
+ x: 6,
+ y: rightY,
+ w: 6,
+ h: m.defaultSize.h,
+ });
+ rightY += m.defaultSize.h;
+ }
+ });
+
+ return { version: 1, widgets: placements };
+}
+
+function glanceLayout(metas: SerializedWidgetMeta[]): DashboardLayout {
+ const placements: WidgetPlacement[] = [];
+ let y = 0;
+ metas.forEach((m) => {
+ placements.push({
+ widgetId: m.id,
+ config: m.defaultConfig,
+ x: 0,
+ y,
+ w: 12,
+ h: m.defaultSize.h,
+ });
+ y += m.defaultSize.h;
+ });
+ return { version: 1, widgets: placements };
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index 3d9b482..5519687 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -15,6 +15,11 @@ export function middleware(request: NextRequest) {
return response;
}
+function withPathname(response: NextResponse, pathname: string): NextResponse {
+ response.headers.set("x-pathname", pathname);
+ return response;
+}
+
function route(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
@@ -38,10 +43,10 @@ function route(request: NextRequest): NextResponse {
}
if (PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) {
- return NextResponse.next();
+ return withPathname(NextResponse.next(), pathname);
}
- if (hasSessionCookie(request)) return NextResponse.next();
+ if (hasSessionCookie(request)) return withPathname(NextResponse.next(), pathname);
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", request.url);
diff --git a/src/modules/_core/manifest.tsx b/src/modules/_core/manifest.tsx
index 9972d17..91de021 100644
--- a/src/modules/_core/manifest.tsx
+++ b/src/modules/_core/manifest.tsx
@@ -22,25 +22,27 @@ async function ActivityWidget({ config }: { config: unknown }) {
.limit(parsed.limit ?? 20);
if (entries.length === 0) {
- return No recent activity
;
+ return No recent activity
;
}
return (
-
-
+
({
- id: event.id,
- title: event.title,
- start: event.startAt,
- end: event.endAt,
- allDay: event.allDay,
- backgroundColor:
+ events={visibleEvents.map((event) => {
+ const color =
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
- DEFAULT_COLOR,
- borderColor:
- calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
- DEFAULT_COLOR,
- }))}
+ DEFAULT_COLOR;
+ return {
+ id: event.id,
+ title: event.title,
+ start: event.startAt,
+ end: event.endAt,
+ allDay: event.allDay,
+ backgroundColor: withAlpha(color, 0.14),
+ borderColor: color,
+ textColor: "var(--ink-2)",
+ extendedProps: { calendarId: event.calendarId, color },
+ };
+ })}
+ eventClassNames={(arg) => {
+ const id = String(arg.event.extendedProps["calendarId"] ?? "");
+ return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
+ }}
height="auto"
/>
diff --git a/src/modules/calendar/components/shared-view.tsx b/src/modules/calendar/components/shared-view.tsx
index 44c4acb..56cfae8 100644
--- a/src/modules/calendar/components/shared-view.tsx
+++ b/src/modules/calendar/components/shared-view.tsx
@@ -1,77 +1,167 @@
-import { MapPin, StickyNote } from "lucide-react";
+import { Calendar as CalendarIcon, MapPin } from "lucide-react";
import type { CalendarShareData, EventShareData } from "../server/share-queries";
+import { ShareEyebrow } from "@/components/share/share-eyebrow";
+import { MiniDayCard } from "@/components/share/mini-day-card";
+import { MiniMapCard } from "@/components/share/mini-map-card";
+import { ShareDetailCard, ShareRow } from "@/components/share/share-detail-card";
-function formatEventTime(startAt: string, endAt: string, allDay: boolean): string {
+function formatTime(d: Date): string {
+ return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
+}
+
+function formatTimeRange(startAt: string, endAt: string, allDay: boolean): string {
const start = new Date(startAt);
const end = new Date(endAt);
- if (allDay) {
- return start.toLocaleDateString(undefined, { weekday: "short", month: "long", day: "numeric" });
- }
- const dateStr = start.toLocaleDateString(undefined, {
- weekday: "short",
+ if (allDay) return "All day";
+ return `${formatTime(start)} – ${formatTime(end)}`;
+}
+
+function formatFullDate(d: Date): string {
+ return d.toLocaleDateString(undefined, {
+ weekday: "long",
month: "long",
day: "numeric",
+ year: "numeric",
});
- const startTime = start.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
- const endTime = end.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
- return `${dateStr} · ${startTime}–${endTime}`;
}
export function EventSharedView({ data }: { data: EventShareData }) {
+ const start = new Date(data.startAt);
+ const end = new Date(data.endAt);
+ const time = data.allDay ? "All day" : `${formatTime(start)} – ${formatTime(end)}`;
+
return (
-
-
-
- {data.calendarName}
+ <>
+
+
+ Event
+
+
+ {data.title}
+
+ {data.calendarName && (
+
+ On the {data.calendarName} calendar.
- {data.title}
-
- {formatEventTime(data.startAt, data.endAt, data.allDay)}
-
-
- {data.location && (
-
-
- {data.location}
-
)}
+
+
+
+ {data.location && }
+
+
+
+
+ {formatFullDate(start)}
+ {!data.allDay && (
+ <>
+ {" · "}
+ {formatTimeRange(data.startAt, data.endAt, data.allDay)}
+ >
+ )}
+
+ {data.location && (
+
+
+
+ {data.location}
+
+
+ )}
+ {data.calendarName}
+
+
{data.notes && (
-
+
+ {data.notes}
+
)}
-
+ >
);
}
export function CalendarSharedView({ data }: { data: CalendarShareData }) {
return (
-
-
- {data.events.length === 0 ? (
-
No upcoming events.
- ) : (
-
- {data.events.map((event) => (
- -
- {event.title}
-
- {formatEventTime(event.startAt, event.endAt, event.allDay)}
-
- {event.location && (
-
-
- {event.location}
-
- )}
-
- ))}
-
- )}
-
+ <>
+
+
+ Calendar
+
+
+ {data.name}
+
+
+ Upcoming events — next 90 days.
+
+
+
+ {data.events.length === 0 ? (
+ No upcoming events.
+ ) : (
+ data.events.map((event) => {
+ const start = new Date(event.startAt);
+ return (
+
+
+
+
{event.title}
+
+ {start.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ })}
+ {!event.allDay && ` · ${formatTime(start)}`}
+ {event.location && ` · ${event.location}`}
+
+
+
+ );
+ })
+ )}
+
+ >
);
}
diff --git a/src/modules/calendar/manifest.tsx b/src/modules/calendar/manifest.tsx
index 3c04dbc..bc89868 100644
--- a/src/modules/calendar/manifest.tsx
+++ b/src/modules/calendar/manifest.tsx
@@ -18,6 +18,22 @@ const upcomingConfigSchema = z.object({
const monthConfigSchema = z.object({ calendarIds: calendarIdsSchema });
+function dayLabel(d: Date): string {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const tomorrow = new Date(today);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ const target = new Date(d);
+ target.setHours(0, 0, 0, 0);
+ if (target.getTime() === today.getTime()) return "Today";
+ if (target.getTime() === tomorrow.getTime()) return "Tomorrow";
+ return d.toLocaleDateString(undefined, { weekday: "long" });
+}
+
+function formatTime(d: Date): string {
+ return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
+}
+
async function UpcomingEventsWidget({ config }: { config: unknown; ctx: WidgetContext }) {
const parsed = upcomingConfigSchema.parse(config);
const now = new Date();
@@ -32,26 +48,52 @@ async function UpcomingEventsWidget({ config }: { config: unknown; ctx: WidgetCo
);
}
+ // Group by day
+ const groups = new Map();
+ for (const e of events) {
+ const start = new Date(e.startAt);
+ const key = start.toDateString();
+ if (!groups.has(key)) groups.set(key, { day: start, events: [] });
+ groups.get(key)!.events.push(e);
+ }
+
return (
-
- {events.slice(0, 8).map((event) => {
- const start = new Date(event.startAt);
- const label = event.allDay
- ? start.toLocaleDateString(undefined, { month: "short", day: "numeric" })
- : start.toLocaleString(undefined, {
- month: "short",
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- });
- return (
- -
- {label}
- {event.title}
-
- );
- })}
-
+
+ {[...groups.values()].slice(0, 4).map((group, gi) => (
+
+
+ {dayLabel(group.day)}
+
+ {group.day.toLocaleDateString(undefined, { month: "short", day: "numeric" })}
+
+
+
+ {group.events.map((event) => {
+ const start = new Date(event.startAt);
+ return (
+
+
+
+ {event.allDay ? "all day" : formatTime(start)}
+
+
+
+ {event.title}
+
+ {event.location && (
+
{event.location}
+ )}
+
+
+ );
+ })}
+
+
+ ))}
+
);
}
@@ -69,28 +111,29 @@ async function MonthWidget({ config }: { config: unknown; ctx: WidgetContext })
const monthName = now.toLocaleDateString(undefined, { month: "long", year: "numeric" });
return (
-
-
{monthName}
+
+
{monthName}
{events.length === 0 ? (
-
No events this month
+
No events this month
) : (
-
);
diff --git a/src/modules/lists/components/list-detail.tsx b/src/modules/lists/components/list-detail.tsx
index 6f7bcbc..a238077 100644
--- a/src/modules/lists/components/list-detail.tsx
+++ b/src/modules/lists/components/list-detail.tsx
@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
-import { Archive, GripVertical, Plus, Trash2 } from "lucide-react";
+import { Archive, Plus, Trash2 } from "lucide-react";
import { useEffect, useRef, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -23,7 +23,6 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
const [draft, setDraft] = useState("");
const [isPending, startTransition] = useTransition();
const inputRef = useRef
(null);
- const swipeStart = useRef>({});
useEffect(() => {
const events = new EventSource(`/api/lists/${initialList.id}/events`);
@@ -95,97 +94,157 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
});
}
+ const openItems = list.items.filter((i) => !i.done);
+ const doneItems = list.items.filter((i) => i.done);
+
return (
-
+
-
+
setList({ ...list, name: event.target.value })}
onBlur={commitListName}
/>
-
- {list.type} / {list.openCount} open / {list.doneCount} done
-
+
+ {list.type} · {list.openCount} open
+
+
+
-
-
-
- Archive list
-
-
);
}
+
+function ListItemRow({
+ item,
+ onToggle,
+ onEdit,
+ onCommit,
+ onRemove,
+}: {
+ item: ListItemDto;
+ onToggle: (done: boolean) => void;
+ onEdit: (text: string) => void;
+ onCommit: () => void;
+ onRemove: () => void;
+}) {
+ const swipeStart = useRef(null);
+ return (
+ {
+ swipeStart.current = event.clientX;
+ }}
+ onPointerUp={(event) => {
+ const start = swipeStart.current;
+ if (start !== null && event.clientX - start < -60) onRemove();
+ swipeStart.current = null;
+ }}
+ >
+
onToggle(!item.done)}
+ >
+ {item.done && (
+
+ )}
+
+
onEdit(event.target.value)}
+ onBlur={onCommit}
+ />
+
+
+
+
+ );
+}
diff --git a/src/modules/lists/components/list-widget.tsx b/src/modules/lists/components/list-widget.tsx
index 91db5d3..75bb2d1 100644
--- a/src/modules/lists/components/list-widget.tsx
+++ b/src/modules/lists/components/list-widget.tsx
@@ -24,31 +24,50 @@ export function ListWidget({ initialItems }: { initialItems: WidgetItem[] }) {
}
if (items.length === 0) {
- return No open items
;
+ return No open items
;
}
return (
-
+
{items.map((item) => (
-
-
-
+ toggle(item, e.target.checked)}
- />
+ aria-pressed={item.done}
+ className={`checkbox ${item.done ? "on" : ""}`}
+ onClick={() => toggle(item, !item.done)}
+ >
+ {item.done && (
+
+ )}
+
{item.text}
- {item.listName}
-
+
+ {item.listName}
+
+
))}
-
+
);
}
diff --git a/src/modules/lists/components/lists-index.tsx b/src/modules/lists/components/lists-index.tsx
index be1f251..aa1e651 100644
--- a/src/modules/lists/components/lists-index.tsx
+++ b/src/modules/lists/components/lists-index.tsx
@@ -62,13 +62,16 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
}
return (
-
+
-
Lists
-
Shopping, tasks, and whatever comes next.
+
Lists
+
Shopping, tasks, and whatever comes next.
-
+
@@ -95,9 +98,7 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
{grouped.map(([groupType, groupLists]) => (
-
- {groupType}
-
+ {groupType}
{groupLists.map((list) => (
@@ -120,25 +121,32 @@ function ListCard({
const [expanded, setExpanded] = useState(true);
return (
-
-
-
setExpanded((v) => !v)}
- className="text-muted-foreground hover:text-foreground transition-colors"
- aria-label={expanded ? "Collapse" : "Expand"}
- >
- {expanded ? : }
-
-
-
{list.name}
-
- {list.openCount} open · {list.doneCount} done
+
+
+
+
setExpanded((v) => !v)}
+ className="text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
+ aria-label={expanded ? "Collapse" : "Expand"}
+ >
+ {expanded ? : }
+
+
+
+ {list.name}
+
+
+ {list.openCount} open · {list.doneCount} done
+
@@ -146,42 +154,55 @@ function ListCard({
{expanded && (
-
+
{list.items.length === 0 ? (
-
+
{list.openCount === 0 ? "All done!" : "No items to show."}
) : (
-
+
)}
)}
diff --git a/src/modules/lists/components/shared-view.tsx b/src/modules/lists/components/shared-view.tsx
index af50e39..26d6e8d 100644
--- a/src/modules/lists/components/shared-view.tsx
+++ b/src/modules/lists/components/shared-view.tsx
@@ -1,8 +1,10 @@
"use client";
+import { ListChecks } from "lucide-react";
import { useOptimistic, useTransition } from "react";
import type { ListShareData, ListShareItem } from "../server/share-queries";
import { toggleShareListItem } from "../server/share-actions";
+import { ShareEyebrow } from "@/components/share/share-eyebrow";
export function ListSharedView({
data,
@@ -33,38 +35,59 @@ export function ListSharedView({
const done = optimisticItems.filter((i) => i.done);
return (
-
-
- {data.name}
- {data.type}
-
+ <>
+
+
+ List
+
+
+ {data.name}
+
+
{data.type}
{optimisticItems.length === 0 ? (
-
This list is empty.
+
This list is empty.
) : (
-
+
{open.length > 0 && (
-
+ <>
{open.map((item) => (
))}
-
+ >
)}
{done.length > 0 && (
-
- {done.length} completed
+
+ Done · {done.length}
-
- {done.map((item) => (
-
- ))}
-
+ {done.map((item) => (
+
+ ))}
)}
)}
-
+ >
);
}
@@ -78,25 +101,34 @@ function ItemRow({
onToggle: (item: ListShareItem) => void;
}) {
return (
-
- {canWrite ? (
- onToggle(item)}
- />
- ) : (
-
- )}
-
+
+
onToggle(item)}
+ >
+ {item.done && (
+
+ )}
+
+
{item.text}
- {item.qty && ×{item.qty}}
+ {item.qty && ×{item.qty}}
-
+
);
}
diff --git a/src/modules/notes/components/note-editor.tsx b/src/modules/notes/components/note-editor.tsx
index ba03812..4f5cc61 100644
--- a/src/modules/notes/components/note-editor.tsx
+++ b/src/modules/notes/components/note-editor.tsx
@@ -64,35 +64,40 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
}
return (
-