Apply paper-and-ink design system across all surfaces
Release / build-and-push (push) Has been cancelled
Release / build-and-push (push) Has been cancelled
Replaces the generic shadcn/ui gray theme + horizontal top-bar shell with the paper-and-ink language from the Claude Design handoff bundle: warm off-white paper, near-black ink, Source Serif 4 + Inter, hairline borders, muted ink accents (clay/indigo/sage/plum/ochre) used functionally for calendars and share scopes. Theme switcher expanded from 2 dimensions (theme × mode) to 7: palette × mode × fontPair × density × dashLayout × calView × navStyle. All exposed in Settings → Appearance and persisted on the users row. Pre-paint script applies all four data-* attributes from localStorage so reload doesn't flash. App shell restructured to a CSS-grid driven by data-nav on <html>: sidebar on desktop, bottom-nav + FAB under 760px. Four desktop nav modes wired (sidebar/rail/top/fab-only). Topbar gets a search-→-CommandPalette button, notification bell, "+ New" quick-add, avatar. Dashboard, calendar, lists, notes, settings, login, public share viewer, and quick-add sheet all reskinned. Dashboard editor gains a Preset menu (classic/split/glance) that fills the layout from the registered widgets. FullCalendar wrapped in .fc-skin and inherits all paper-and-ink tokens via CSS variable overrides. Public share viewer (/s/<token>) rebuilt around ShareFrame: expiration banner, brand strip, eyebrow chip, 38px serif title, mini-day + mini-map cards, share-rows. Schema: drops users.theme; adds theme_palette, theme_font_pair, theme_density, theme_dash_layout, theme_cal_view, theme_nav_style with defaults that match the design (clay / serif-sans / regular / classic / month / rail-desktop). Migration 0014_paper_ink_theme. Middleware sets x-pathname so the AppShell server component can render bare for /s/* and /login without a route-group refactor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e130cda6c5
commit
9612a54e52
@@ -48,3 +48,6 @@ tests/.auth/
|
||||
|
||||
# Backups
|
||||
deploy/backups/data/
|
||||
|
||||
# Design handoff bundle (reference only, not committed)
|
||||
.design-tmp/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "users" ADD COLUMN "theme_palette" text NOT NULL DEFAULT 'clay';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "theme_font_pair" text NOT NULL DEFAULT 'serif-sans';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "theme_density" text NOT NULL DEFAULT 'regular';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "theme_dash_layout" text NOT NULL DEFAULT 'classic';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "theme_cal_view" text NOT NULL DEFAULT 'month';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "theme_nav_style" text NOT NULL DEFAULT 'rail-desktop';--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "theme";
|
||||
@@ -99,6 +99,13 @@
|
||||
"when": 1778400000000,
|
||||
"tag": "0013_push_notify_reminders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1778600000000,
|
||||
"tag": "0014_paper_ink_theme",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+9
-1
@@ -5,7 +5,15 @@ import nextConfig from "eslint-config-next/core-web-vitals";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["node_modules/**", ".next/**", ".claude/**", "dist/**", "drizzle/**", "public/sw.js"],
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
".claude/**",
|
||||
".design-tmp/**",
|
||||
"dist/**",
|
||||
"drizzle/**",
|
||||
"public/sw.js",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
||||
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import type { CalView } from "@/modules/_core/themes";
|
||||
|
||||
export default async function CalendarPage() {
|
||||
const now = new Date();
|
||||
@@ -8,10 +10,17 @@ export default async function CalendarPage() {
|
||||
const to = new Date(now);
|
||||
to.setMonth(to.getMonth() + 10);
|
||||
|
||||
const [calendars, events] = await Promise.all([
|
||||
const [{ user }, calendars, events] = await Promise.all([
|
||||
getCurrentSession(),
|
||||
listCalendars(),
|
||||
listEvents({ from, to, calendarIds: "all" }),
|
||||
]);
|
||||
|
||||
return <CalendarShell calendars={calendars} events={events} />;
|
||||
return (
|
||||
<CalendarShell
|
||||
calendars={calendars}
|
||||
events={events}
|
||||
defaultView={user.themeCalView as CalView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+58
-10
@@ -1,13 +1,20 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { parseDashboardLayout, computeDefaultLayout } from "@/lib/dashboard";
|
||||
import { parseDashboardLayout } from "@/lib/dashboard";
|
||||
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
||||
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
||||
import { getDashboardBySlug } from "@/app/d/actions";
|
||||
import { DashboardEditor } from "@/components/dashboard-editor";
|
||||
import { QuickAddFab } from "@/components/quick-add-fab";
|
||||
import { EditDashboardButton } from "@/components/edit-dashboard-button";
|
||||
import { DashboardSwitcher } from "@/components/dashboard-switcher";
|
||||
import { DashboardTab } from "@/components/dashboard-tab";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { dashboards } from "@/modules/_core/schema";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { DashLayout } from "@/modules/_core/themes";
|
||||
|
||||
const smColSpan: Record<number, string> = {
|
||||
1: "sm:col-span-1",
|
||||
@@ -24,6 +31,9 @@ const smColSpan: Record<number, string> = {
|
||||
12: "sm:col-span-12",
|
||||
};
|
||||
|
||||
const greetingForHour = (h: number) =>
|
||||
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
|
||||
|
||||
export default async function DashboardPage({
|
||||
params,
|
||||
searchParams,
|
||||
@@ -52,17 +62,55 @@ export default async function DashboardPage({
|
||||
);
|
||||
}
|
||||
|
||||
// For dashboard tabs sub-header, pull the user's dashboards (small list).
|
||||
const session = await auth();
|
||||
const userDashboards = session?.user?.id
|
||||
? await db
|
||||
.select({
|
||||
id: dashboards.id,
|
||||
name: dashboards.name,
|
||||
slug: dashboards.slug,
|
||||
isDefault: dashboards.isDefault,
|
||||
position: dashboards.position,
|
||||
})
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.userId, session.user.id))
|
||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt))
|
||||
: [];
|
||||
|
||||
const ctx = { userId: user.id, householdId: household.id };
|
||||
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
const dashLayout = (user.themeDashLayout as DashLayout) ?? "classic";
|
||||
const containerCls =
|
||||
dashLayout === "glance" ? "max-w-[760px] mx-auto" : dashLayout === "split" ? "" : "";
|
||||
const greeting = greetingForHour(new Date().getHours());
|
||||
const today = new Date().toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const firstName = (user.name ?? "").split(" ")[0] ?? "";
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{dashboard.name}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<QuickAddFab />
|
||||
<EditDashboardButton />
|
||||
<div className={containerCls}>
|
||||
{userDashboards.length > 1 && (
|
||||
<div className="flex items-center gap-1 mb-4 overflow-x-auto">
|
||||
{userDashboards.map((d) => (
|
||||
<DashboardTab key={d.id} slug={d.slug} name={d.name} />
|
||||
))}
|
||||
<DashboardSwitcher dashboards={userDashboards} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="serif text-[26px] sm:text-[30px] leading-tight tracking-tight">
|
||||
{greeting}
|
||||
{firstName && `, ${firstName}`}.
|
||||
</h1>
|
||||
<p className="muted mt-1 text-[13px]">{today}</p>
|
||||
</div>
|
||||
<EditDashboardButton />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
|
||||
@@ -73,8 +121,8 @@ export default async function DashboardPage({
|
||||
return (
|
||||
<div key={i} className={`col-span-1 ${colClass}`}>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{widget.title}</CardTitle>
|
||||
<CardHeader>
|
||||
<CardTitle>{widget.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense
|
||||
|
||||
@@ -8,7 +8,8 @@ import { db } from "@/lib/db";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { getWidget } from "@/modules/_core";
|
||||
import { dashboards } from "@/modules/_core/schema";
|
||||
import { computeDefaultLayout, type DashboardLayout } from "@/lib/dashboard";
|
||||
import { type DashboardLayout } from "@/lib/dashboard";
|
||||
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
||||
|
||||
export type DashboardMeta = {
|
||||
id: string;
|
||||
|
||||
+1059
-168
File diff suppressed because it is too large
Load Diff
+86
-25
@@ -1,8 +1,7 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { Geist } from "next/font/google";
|
||||
import { Inter, Source_Serif_4, Newsreader, Fraunces, JetBrains_Mono } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AppNav } from "@/components/app-nav";
|
||||
import "@/modules"; // registers all module manifests
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
@@ -15,11 +14,34 @@ import { QuickAddSheet } from "@/components/quick-add-sheet";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { PwaRegister } from "@/components/pwa-register";
|
||||
import { InstallPrompt } from "@/components/install-prompt";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
|
||||
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--sans-inter", display: "swap" });
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin"],
|
||||
variable: "--serif-source",
|
||||
display: "swap",
|
||||
});
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--serif-newsreader",
|
||||
display: "swap",
|
||||
});
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
variable: "--serif-fraunces",
|
||||
display: "swap",
|
||||
});
|
||||
const jetbrains = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--mono-jb",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#4F46E5",
|
||||
themeColor: "#1F1B16",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -36,34 +58,60 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
// Runs before paint — reads localStorage / prefers-color-scheme and applies
|
||||
// data-theme + dark class to <html> so signed-out pages also get the right theme.
|
||||
// Pre-paint: read user's theme prefs from localStorage and apply data-* + .dark.
|
||||
// Falls back to clay/serif-sans/regular/sidebar/system if nothing is stored.
|
||||
const prePaintScript = `(function(){
|
||||
try {
|
||||
var t = localStorage.getItem('theme') || 'default';
|
||||
var m = localStorage.getItem('themeMode') || 'system';
|
||||
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
if (dark) document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
var palette = localStorage.getItem('themePalette') || localStorage.getItem('theme') || 'clay';
|
||||
var mode = localStorage.getItem('themeMode') || 'system';
|
||||
var fontPair = localStorage.getItem('themeFontPair') || 'serif-sans';
|
||||
var density = localStorage.getItem('themeDensity') || 'regular';
|
||||
var navStyle = localStorage.getItem('themeNavStyle') || 'rail-desktop';
|
||||
var dataNav = navStyle === 'compact-rail' ? 'rail'
|
||||
: navStyle === 'top-nav' ? 'top'
|
||||
: navStyle === 'fab-only' ? 'fab' : 'sidebar';
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 759px)').matches) {
|
||||
dataNav = navStyle === 'fab-only' ? 'fab' : 'bottom';
|
||||
}
|
||||
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
var html = document.documentElement;
|
||||
html.setAttribute('data-theme', palette);
|
||||
html.setAttribute('data-font-pair', fontPair);
|
||||
html.setAttribute('data-density', density);
|
||||
html.setAttribute('data-nav', dataNav);
|
||||
if (dark) html.classList.add('dark'); else html.classList.remove('dark');
|
||||
} catch(e) {}
|
||||
})();`;
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
let theme = "default";
|
||||
let themeMode = "system";
|
||||
let palette: Palette = DEFAULT_THEME.palette;
|
||||
let mode: ThemeMode = DEFAULT_THEME.mode;
|
||||
let fontPair: FontPair = DEFAULT_THEME.fontPair;
|
||||
let density: Density = DEFAULT_THEME.density;
|
||||
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
|
||||
let userDashboards: DashboardMeta[] = [];
|
||||
let signedIn = false;
|
||||
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
signedIn = true;
|
||||
const [row] = await db
|
||||
.select({ theme: users.theme, themeMode: users.themeMode })
|
||||
.select({
|
||||
themePalette: users.themePalette,
|
||||
themeMode: users.themeMode,
|
||||
themeFontPair: users.themeFontPair,
|
||||
themeDensity: users.themeDensity,
|
||||
themeNavStyle: users.themeNavStyle,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, session.user.id))
|
||||
.limit(1);
|
||||
if (row) {
|
||||
theme = row.theme;
|
||||
themeMode = row.themeMode;
|
||||
palette = row.themePalette as Palette;
|
||||
mode = row.themeMode as ThemeMode;
|
||||
fontPair = row.themeFontPair as FontPair;
|
||||
density = row.themeDensity as Density;
|
||||
navStyle = row.themeNavStyle as NavStyle;
|
||||
}
|
||||
userDashboards = await db
|
||||
.select({
|
||||
@@ -78,25 +126,38 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
||||
}
|
||||
|
||||
// For system mode we can't know the preference on the server — the inline
|
||||
// script will correct it before paint. We optimistically render light here.
|
||||
const isDark = themeMode === "dark";
|
||||
// Server-side initial dark guess: only for `dark` mode (system mode is corrected
|
||||
// before paint by the inline script). Avoids a flash on signed-in users.
|
||||
const isDark = mode === "dark";
|
||||
const initialDataNav = navStyleToDataNav(navStyle);
|
||||
|
||||
const quickAdds = getQuickAdds();
|
||||
|
||||
const fontVars = cn(
|
||||
inter.variable,
|
||||
sourceSerif.variable,
|
||||
newsreader.variable,
|
||||
fraunces.variable,
|
||||
jetbrains.variable,
|
||||
);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
data-theme={theme}
|
||||
className={cn("font-sans", geist.variable, isDark ? "dark" : "")}
|
||||
data-theme={palette}
|
||||
data-font-pair={fontPair}
|
||||
data-density={density}
|
||||
data-nav={initialDataNav}
|
||||
className={cn(fontVars, isDark ? "dark" : "")}
|
||||
>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
|
||||
</head>
|
||||
<body className="min-h-screen">
|
||||
<body>
|
||||
<QuickAddProvider actions={quickAdds}>
|
||||
<AppNav dashboards={userDashboards} />
|
||||
<main>{children}</main>
|
||||
<AppShell signedIn={signedIn} navStyle={navStyle} dashboards={userDashboards}>
|
||||
{children}
|
||||
</AppShell>
|
||||
<QuickAddSheet />
|
||||
<CommandPalette />
|
||||
<InstallPrompt />
|
||||
|
||||
+17
-5
@@ -4,21 +4,32 @@ import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { DEV_LOGIN_COOKIE, isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||
import { createDevSession } from "@/lib/dev-login";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
|
||||
export default function LoginPage() {
|
||||
const devLoginEnabled = isDevLoginEnabled();
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<h1 className="text-4xl font-bold">famapp</h1>
|
||||
<main
|
||||
className="flex min-h-screen flex-col items-center justify-center p-8"
|
||||
style={{ background: "var(--paper)" }}
|
||||
>
|
||||
<div
|
||||
className="rounded-[var(--r-lg)] shadow-[var(--shadow-2)] p-10 max-w-sm w-full text-center"
|
||||
style={{ background: "var(--card)", border: "0.5px solid var(--hair)" }}
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<BrandMark />
|
||||
</div>
|
||||
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
|
||||
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("authentik", { redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="lg">
|
||||
<Button type="submit" size="lg" className="w-full">
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
</form>
|
||||
@@ -36,8 +47,9 @@ export default function LoginPage() {
|
||||
});
|
||||
redirect("/");
|
||||
}}
|
||||
className="mt-3"
|
||||
>
|
||||
<Button type="submit" variant="outline" size="lg">
|
||||
<Button type="submit" variant="outline" size="lg" className="w-full">
|
||||
Dev login
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
+24
-20
@@ -1,14 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { resolveShareToken } from "@/modules/_core/share";
|
||||
import { getEntityType } from "@/modules/_core/registry";
|
||||
import { isRateLimited, recordFailure } from "@/lib/rate-limit";
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/modules/_core/schema";
|
||||
import { ShareFrame } from "@/components/share/share-frame";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
// Rate-limit prefix length — must match the value used in middleware.
|
||||
const RL_PREFIX_LEN = 8;
|
||||
|
||||
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
|
||||
@@ -20,19 +23,12 @@ export default async function SharePage({ params }: { params: Promise<{ token: s
|
||||
"0.0.0.0";
|
||||
const rlKey = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
|
||||
|
||||
// Secondary rate-limit check in the Node.js runtime (failure-only bucket).
|
||||
// The primary 429 enforcement lives in src/middleware.ts which counts all
|
||||
// requests in the Edge runtime. This page tracks only failed token lookups,
|
||||
// providing accurate per-failure accounting. The two buckets are independent
|
||||
// (separate module instances across runtimes); a shared Redis store would
|
||||
// unify them for multi-replica deployments.
|
||||
if (isRateLimited(rlKey)) {
|
||||
return <ShareRateLimitError />;
|
||||
}
|
||||
|
||||
const resolved = await resolveShareToken(token);
|
||||
if (!resolved) {
|
||||
// Only failed lookups increment the failure bucket.
|
||||
recordFailure(rlKey);
|
||||
return <ShareError />;
|
||||
}
|
||||
@@ -48,24 +44,32 @@ export default async function SharePage({ params }: { params: Promise<{ token: s
|
||||
return <ShareError />;
|
||||
}
|
||||
|
||||
// Best-effort lookup of the creator's display name. Doesn't reveal email.
|
||||
const [creator] = resolved.createdBy
|
||||
? await db
|
||||
.select({ name: users.name })
|
||||
.from(users)
|
||||
.where(eq(users.id, resolved.createdBy))
|
||||
.limit(1)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="border-b bg-background px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shared via famapp
|
||||
{resolved.capabilities.write ? " · You can edit this" : " · View only"}
|
||||
</p>
|
||||
</div>
|
||||
<ShareFrame
|
||||
expiresAt={resolved.expiresAt}
|
||||
capabilities={resolved.capabilities}
|
||||
token={token}
|
||||
sharedByName={creator?.name ?? null}
|
||||
>
|
||||
{entityReg.renderSharedView({ data, capabilities: resolved.capabilities, token })}
|
||||
</div>
|
||||
</ShareFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareRateLimitError() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<h1 className="text-xl font-semibold">Too many requests</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
<h1 className="serif text-[28px] font-medium tracking-tight">Too many requests</h1>
|
||||
<p className="max-w-sm text-[13.5px] muted">
|
||||
You have made too many requests in a short period. Please wait a minute and try again.
|
||||
</p>
|
||||
</div>
|
||||
@@ -75,8 +79,8 @@ function ShareRateLimitError() {
|
||||
function ShareError({ message }: { message?: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<h1 className="text-xl font-semibold">Link not found</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
<h1 className="serif text-[28px] font-medium tracking-tight">Link not found</h1>
|
||||
<p className="max-w-sm text-[13.5px] muted">
|
||||
{message ??
|
||||
"This share link may have expired or been revoked. Ask the sender for a new link."}
|
||||
</p>
|
||||
|
||||
@@ -4,17 +4,73 @@ import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/modules/_core/schema";
|
||||
import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes";
|
||||
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
||||
import {
|
||||
VALID_PALETTES,
|
||||
VALID_THEME_MODES,
|
||||
VALID_FONT_PAIRS,
|
||||
VALID_DENSITIES,
|
||||
VALID_DASH_LAYOUTS,
|
||||
VALID_CAL_VIEWS,
|
||||
VALID_NAV_STYLES,
|
||||
} from "@/modules/_core/themes";
|
||||
import type {
|
||||
Palette,
|
||||
ThemeMode,
|
||||
FontPair,
|
||||
Density,
|
||||
DashLayout,
|
||||
CalView,
|
||||
NavStyle,
|
||||
} from "@/modules/_core/themes";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { revokeShareLink } from "@/modules/_core/share";
|
||||
|
||||
export async function setUserTheme({ theme, mode }: { theme: ThemeId; mode: ThemeMode }) {
|
||||
if (!VALID_THEME_IDS.has(theme)) throw new Error("Invalid theme");
|
||||
if (!VALID_THEME_MODES.has(mode)) throw new Error("Invalid theme mode");
|
||||
export interface ThemePatch {
|
||||
palette?: Palette;
|
||||
mode?: ThemeMode;
|
||||
fontPair?: FontPair;
|
||||
density?: Density;
|
||||
dashLayout?: DashLayout;
|
||||
calView?: CalView;
|
||||
navStyle?: NavStyle;
|
||||
}
|
||||
|
||||
export async function setUserTheme(patch: ThemePatch) {
|
||||
const update: Record<string, string> = {};
|
||||
|
||||
if (patch.palette !== undefined) {
|
||||
if (!VALID_PALETTES.has(patch.palette)) throw new Error("Invalid palette");
|
||||
update["themePalette"] = patch.palette;
|
||||
}
|
||||
if (patch.mode !== undefined) {
|
||||
if (!VALID_THEME_MODES.has(patch.mode)) throw new Error("Invalid theme mode");
|
||||
update["themeMode"] = patch.mode;
|
||||
}
|
||||
if (patch.fontPair !== undefined) {
|
||||
if (!VALID_FONT_PAIRS.has(patch.fontPair)) throw new Error("Invalid font pair");
|
||||
update["themeFontPair"] = patch.fontPair;
|
||||
}
|
||||
if (patch.density !== undefined) {
|
||||
if (!VALID_DENSITIES.has(patch.density)) throw new Error("Invalid density");
|
||||
update["themeDensity"] = patch.density;
|
||||
}
|
||||
if (patch.dashLayout !== undefined) {
|
||||
if (!VALID_DASH_LAYOUTS.has(patch.dashLayout)) throw new Error("Invalid dashboard layout");
|
||||
update["themeDashLayout"] = patch.dashLayout;
|
||||
}
|
||||
if (patch.calView !== undefined) {
|
||||
if (!VALID_CAL_VIEWS.has(patch.calView)) throw new Error("Invalid calendar view");
|
||||
update["themeCalView"] = patch.calView;
|
||||
}
|
||||
if (patch.navStyle !== undefined) {
|
||||
if (!VALID_NAV_STYLES.has(patch.navStyle)) throw new Error("Invalid nav style");
|
||||
update["themeNavStyle"] = patch.navStyle;
|
||||
}
|
||||
|
||||
if (Object.keys(update).length === 0) return;
|
||||
|
||||
const { user } = await getCurrentSession();
|
||||
await db.update(users).set({ theme, themeMode: mode }).where(eq(users.id, user.id));
|
||||
await db.update(users).set(update).where(eq(users.id, user.id));
|
||||
}
|
||||
|
||||
export async function setCompletionVisibilityHours(hours: number): Promise<void> {
|
||||
|
||||
+290
-54
@@ -7,44 +7,183 @@ import { ThemePicker } from "@/components/theme-picker";
|
||||
import { CompletionDelaySetting } from "@/components/completion-delay-setting";
|
||||
import { PushOptIn } from "@/components/push-opt-in";
|
||||
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
|
||||
import { ResponsiveSidebar } from "@/components/settings-section";
|
||||
import type { SectionId } from "@/components/settings-section";
|
||||
import { revokeShareLinkAction } from "./actions";
|
||||
import { listCalendars } from "@/modules/calendar/server/queries";
|
||||
import { listLists } from "@/modules/lists/server/queries";
|
||||
import Link from "next/link";
|
||||
import { NavIcon } from "@/components/nav-icon";
|
||||
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus } from "lucide-react";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const { user } = await getCurrentSession();
|
||||
const shareLinks = await getActiveShareLinks();
|
||||
const VALID_SECTIONS = new Set<SectionId>([
|
||||
"household",
|
||||
"sharing",
|
||||
"notifications",
|
||||
"calendars",
|
||||
"appearance",
|
||||
"data",
|
||||
]);
|
||||
|
||||
export default async function SettingsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ s?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const section: SectionId = VALID_SECTIONS.has(sp.s as SectionId)
|
||||
? (sp.s as SectionId)
|
||||
: "household";
|
||||
|
||||
const { user, household } = await getCurrentSession();
|
||||
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
|
||||
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
||||
|
||||
return (
|
||||
<div className="container max-w-2xl py-8 space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
<div className="grid gap-5 sm:grid-cols-[220px_1fr]">
|
||||
<ResponsiveSidebar active={section} />
|
||||
<div className="space-y-4">
|
||||
{section === "household" && <HouseholdSection household={household} userName={user.name} />}
|
||||
{section === "sharing" && <SharingSection />}
|
||||
{section === "notifications" && (
|
||||
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
|
||||
)}
|
||||
{section === "calendars" && <CalendarsAndListsSection />}
|
||||
{section === "appearance" && <AppearanceSection user={user} />}
|
||||
{section === "data" && <DataSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HouseholdSection({
|
||||
household,
|
||||
userName,
|
||||
}: {
|
||||
household: { id: string; name: string };
|
||||
userName: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardTitle>{household.name}</CardTitle>
|
||||
<span className="meta">Self-hosted</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ThemePicker
|
||||
initialTheme={user.theme as "default" | "warm"}
|
||||
initialMode={user.themeMode as "light" | "dark" | "system"}
|
||||
signedIn
|
||||
/>
|
||||
<p className="muted text-[13px] mb-3">
|
||||
Household name shown on share links and the iOS PWA.
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/household"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-[13px] font-medium border-[0.5px] hover:bg-[var(--shade)]"
|
||||
style={{ borderColor: "var(--hair-2)" }}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit household & members
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lists</CardTitle>
|
||||
<CardTitle>You</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletionDelaySetting initialHours={user.completionVisibilityHours} />
|
||||
<div className="set-row" style={{ borderBottom: "0", padding: 0 }}>
|
||||
<span className="avatar avatar-lg" style={{ background: "var(--c-household)" }}>
|
||||
{(userName ?? "?").trim()[0]?.toUpperCase()}
|
||||
</span>
|
||||
<div className="label">
|
||||
<div className="t">{userName ?? "Anonymous"}</div>
|
||||
<div className="d">Signed in via Authentik</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function SharingSection() {
|
||||
const shareLinks = await getActiveShareLinks();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Public share links</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="muted text-[12.5px] mb-3">
|
||||
Share any calendar, list, note, or event with people outside the household. Links expire
|
||||
on their own — no logins needed.
|
||||
</p>
|
||||
{shareLinks.length === 0 ? (
|
||||
<p className="muted text-[13px]">No active share links.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{shareLinks.map((link) => {
|
||||
const registration = getEntityType(link.entityType);
|
||||
const label = registration?.label.singular ?? link.entityType;
|
||||
const iconName = link.entityType.startsWith("calendar.")
|
||||
? "calendar"
|
||||
: link.entityType.startsWith("notes.")
|
||||
? "note"
|
||||
: link.entityType.startsWith("lists.")
|
||||
? "list"
|
||||
: "link";
|
||||
return (
|
||||
<div
|
||||
key={link.id}
|
||||
className="flex items-center gap-3 rounded-[var(--r-md)] border-[0.5px] p-3"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
<div
|
||||
className="size-9 rounded-md flex items-center justify-center shrink-0"
|
||||
style={{ background: "var(--paper-2)" }}
|
||||
>
|
||||
<NavIcon name={iconName} className="size-4 text-[var(--ink-soft)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13.5px] font-medium">{label}</div>
|
||||
<div className="flex flex-wrap gap-2 items-center text-[11.5px] muted mt-0.5">
|
||||
<code style={{ fontFamily: "var(--mono)" }}>
|
||||
{link.capabilities.write ? "edit" : "view-only"}
|
||||
</code>
|
||||
{link.expiresAt && (
|
||||
<span>· expires {link.expiresAt.toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<form action={revokeShareLinkAction}>
|
||||
<input type="hidden" name="id" value={link.id} />
|
||||
<Button variant="destructive" size="sm" type="submit">
|
||||
Revoke
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsSection({
|
||||
user,
|
||||
vapidKey,
|
||||
ntfyConfigured,
|
||||
}: {
|
||||
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
|
||||
vapidKey: string;
|
||||
ntfyConfigured: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Push Notifications</CardTitle>
|
||||
<CardTitle>Push notifications</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PushOptIn vapidKey={vapidKey} />
|
||||
@@ -53,7 +192,7 @@ export default async function SettingsPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Channels</CardTitle>
|
||||
<CardTitle>Notification channels</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NotifyChannelToggles
|
||||
@@ -64,52 +203,149 @@ export default async function SettingsPage() {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function CalendarsAndListsSection() {
|
||||
const [calendars, lists] = await Promise.all([listCalendars(), listLists()]);
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Calendars</CardTitle>
|
||||
<Link
|
||||
href="/calendar"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[12.5px] hover:bg-[var(--shade)]"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<div>
|
||||
{calendars.length === 0 ? (
|
||||
<div className="muted px-[14px] py-4 text-[13px]">No calendars yet.</div>
|
||||
) : (
|
||||
calendars.map((c) => (
|
||||
<div key={c.id} className="set-row">
|
||||
<span
|
||||
className="dot"
|
||||
style={{ background: c.color ?? "var(--c-household)", width: 12, height: 12 }}
|
||||
/>
|
||||
<div className="label">
|
||||
<div className="t">{c.name}</div>
|
||||
<div className="d">
|
||||
{c.visibility === "private" ? "Private" : "Household · everyone sees it"}
|
||||
</div>
|
||||
</div>
|
||||
{c.visibility === "private" && <Lock className="size-3.5 text-[var(--ink-mute)]" />}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Share Links</CardTitle>
|
||||
<CardTitle>Lists</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{shareLinks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active share links.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{shareLinks.map((link) => {
|
||||
const registration = getEntityType(link.entityType);
|
||||
const label = registration?.label.singular ?? link.entityType;
|
||||
return (
|
||||
<li key={link.id} className="flex items-center justify-between gap-4 text-sm">
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{link.capabilities.write ? "read + write" : "read-only"}
|
||||
</span>
|
||||
{link.expiresAt && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
· expires {link.expiresAt.toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<form action={revokeShareLinkAction}>
|
||||
<input type="hidden" name="id" value={link.id} />
|
||||
<Button variant="destructive" size="sm" type="submit">
|
||||
Revoke
|
||||
</Button>
|
||||
</form>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<CompletionDelaySetting initialHours={24} />
|
||||
</CardContent>
|
||||
<div>
|
||||
{lists.length === 0 ? (
|
||||
<div className="muted px-[14px] py-4 text-[13px]">No lists yet.</div>
|
||||
) : (
|
||||
lists.map((l) => (
|
||||
<div key={l.id} className="set-row">
|
||||
<NavIcon
|
||||
name={l.type === "shopping" ? "cart" : "check-square"}
|
||||
className="size-4 text-[var(--ink-soft)]"
|
||||
/>
|
||||
<div className="label">
|
||||
<div className="t">{l.name}</div>
|
||||
<div className="d">
|
||||
{l.type} · {l.openCount} open
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<Link
|
||||
href="/settings/household"
|
||||
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Household Settings
|
||||
</Link>
|
||||
</div>
|
||||
function AppearanceSection({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
themePalette: string;
|
||||
themeMode: string;
|
||||
themeFontPair: string;
|
||||
themeDensity: string;
|
||||
themeDashLayout: string;
|
||||
themeCalView: string;
|
||||
themeNavStyle: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<Sun className="size-4 text-[var(--ink-mute)]" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ThemePicker
|
||||
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
|
||||
initialMode={user.themeMode as "light" | "dark" | "system"}
|
||||
initialFontPair={
|
||||
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
|
||||
}
|
||||
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
|
||||
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
|
||||
initialCalView={user.themeCalView as "month" | "week" | "day"}
|
||||
initialNavStyle={
|
||||
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
|
||||
}
|
||||
signedIn
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DataSection() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Data & backups</CardTitle>
|
||||
</CardHeader>
|
||||
<div>
|
||||
<div className="set-row">
|
||||
<History className="size-4 text-[var(--ink-soft)]" />
|
||||
<div className="label">
|
||||
<div className="t">Auto-backup</div>
|
||||
<div className="d">Daily 03:00 → /var/backups/famapp/. Configured via host cron.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-row">
|
||||
<Globe className="size-4 text-[var(--ink-soft)]" />
|
||||
<div className="label">
|
||||
<div className="t">Server</div>
|
||||
<div className="d">Self-hosted via Docker Compose</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-row" style={{ borderBottom: "0" }}>
|
||||
<Mail className="size-4 text-[var(--ink-soft)]" />
|
||||
<div className="label">
|
||||
<div className="t">Export</div>
|
||||
<div className="d">Not yet implemented — coming in v0.5</div>
|
||||
</div>
|
||||
<Bell className="size-4 text-[var(--ink-faint)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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] ?? "➕") : "➕";
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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's famapp household and may
|
||||
change — we'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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
+155
-22
@@ -1,49 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type {
|
||||
Palette,
|
||||
ThemeMode,
|
||||
FontPair,
|
||||
Density,
|
||||
DashLayout,
|
||||
CalView,
|
||||
NavStyle,
|
||||
} from "@/modules/_core/themes";
|
||||
import { navStyleToDataNav } from "@/modules/_core/themes";
|
||||
import { setUserTheme } from "@/app/settings/actions";
|
||||
|
||||
function applyTheme(theme: ThemeId, mode: ThemeMode) {
|
||||
const MOBILE_QUERY = "(max-width: 759px)";
|
||||
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
function applyTheme(state: {
|
||||
palette: Palette;
|
||||
mode: ThemeMode;
|
||||
fontPair: FontPair;
|
||||
density: Density;
|
||||
navStyle: NavStyle;
|
||||
}) {
|
||||
const html = document.documentElement;
|
||||
html.setAttribute("data-theme", theme);
|
||||
html.setAttribute("data-theme", state.palette);
|
||||
html.setAttribute("data-font-pair", state.fontPair);
|
||||
html.setAttribute("data-density", state.density);
|
||||
|
||||
const isMobile = window.matchMedia(MOBILE_QUERY).matches;
|
||||
const desktopNav = navStyleToDataNav(state.navStyle);
|
||||
const dataNav = isMobile ? (state.navStyle === "fab-only" ? "fab" : "bottom") : desktopNav;
|
||||
html.setAttribute("data-nav", dataNav);
|
||||
|
||||
const dark =
|
||||
mode === "dark" ||
|
||||
(mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
state.mode === "dark" || (state.mode === "system" && window.matchMedia(DARK_QUERY).matches);
|
||||
html.classList.toggle("dark", dark);
|
||||
|
||||
try {
|
||||
localStorage.setItem("theme", theme);
|
||||
localStorage.setItem("themeMode", mode);
|
||||
localStorage.setItem("themePalette", state.palette);
|
||||
localStorage.setItem("themeMode", state.mode);
|
||||
localStorage.setItem("themeFontPair", state.fontPair);
|
||||
localStorage.setItem("themeDensity", state.density);
|
||||
localStorage.setItem("themeNavStyle", state.navStyle);
|
||||
} catch {
|
||||
// storage blocked
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme(
|
||||
initialTheme: ThemeId = "default",
|
||||
initialMode: ThemeMode = "system",
|
||||
initial: {
|
||||
palette?: Palette;
|
||||
mode?: ThemeMode;
|
||||
fontPair?: FontPair;
|
||||
density?: Density;
|
||||
dashLayout?: DashLayout;
|
||||
calView?: CalView;
|
||||
navStyle?: NavStyle;
|
||||
} = {},
|
||||
signedIn = false,
|
||||
) {
|
||||
const [theme, setThemeState] = useState<ThemeId>(initialTheme);
|
||||
const [mode, setModeState] = useState<ThemeMode>(initialMode);
|
||||
const [palette, setPaletteState] = useState<Palette>(initial.palette ?? "clay");
|
||||
const [mode, setModeState] = useState<ThemeMode>(initial.mode ?? "system");
|
||||
const [fontPair, setFontPairState] = useState<FontPair>(initial.fontPair ?? "serif-sans");
|
||||
const [density, setDensityState] = useState<Density>(initial.density ?? "regular");
|
||||
const [dashLayout, setDashLayoutState] = useState<DashLayout>(initial.dashLayout ?? "classic");
|
||||
const [calView, setCalViewState] = useState<CalView>(initial.calView ?? "month");
|
||||
const [navStyle, setNavStyleState] = useState<NavStyle>(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<typeof setUserTheme>[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,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-6
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+97
-26
@@ -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 };
|
||||
}
|
||||
|
||||
+7
-2
@@ -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);
|
||||
|
||||
@@ -22,25 +22,27 @@ async function ActivityWidget({ config }: { config: unknown }) {
|
||||
.limit(parsed.limit ?? 20);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No recent activity</p>;
|
||||
return <p className="text-sm text-[var(--ink-mute)]">No recent activity</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
<div className="flex flex-col">
|
||||
{entries.map((entry) => {
|
||||
const reg = getEntityType(entry.entityType);
|
||||
const description =
|
||||
reg?.renderActivity?.(entry as ActivityLogEntry) ?? `${entry.action} ${entry.entityType}`;
|
||||
return (
|
||||
<li key={entry.id} className="flex items-start gap-2 text-sm">
|
||||
<span className="mt-0.5 shrink-0 text-xs text-muted-foreground">
|
||||
<div key={entry.id} className="activity-row">
|
||||
<div className="flex-1 leading-[1.4]">
|
||||
<span className="obj">{description}</span>
|
||||
</div>
|
||||
<time>
|
||||
{entry.createdAt.toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<span className="leading-snug">{description}</span>
|
||||
</li>
|
||||
</time>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,13 @@ export const users = pgTable("users", {
|
||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||
emailVerified: timestamp("email_verified", { withTimezone: true }),
|
||||
image: text("image"),
|
||||
theme: text("theme").notNull().default("default"),
|
||||
themePalette: text("theme_palette").notNull().default("clay"),
|
||||
themeMode: text("theme_mode").notNull().default("system"),
|
||||
themeFontPair: text("theme_font_pair").notNull().default("serif-sans"),
|
||||
themeDensity: text("theme_density").notNull().default("regular"),
|
||||
themeDashLayout: text("theme_dash_layout").notNull().default("classic"),
|
||||
themeCalView: text("theme_cal_view").notNull().default("month"),
|
||||
themeNavStyle: text("theme_nav_style").notNull().default("rail-desktop"),
|
||||
completionVisibilityHours: integer("completion_visibility_hours").notNull().default(24),
|
||||
notifPush: boolean("notif_push").notNull().default(true),
|
||||
notifInApp: boolean("notif_inapp").notNull().default(true),
|
||||
|
||||
@@ -64,6 +64,8 @@ export async function resolveShareToken(rawToken: string): Promise<{
|
||||
entityId: string;
|
||||
capabilities: ShareLinkCapabilities;
|
||||
householdId: string;
|
||||
expiresAt: Date | null;
|
||||
createdBy: string;
|
||||
} | null> {
|
||||
const tokenHash = hashToken(rawToken);
|
||||
|
||||
@@ -82,6 +84,8 @@ export async function resolveShareToken(rawToken: string): Promise<{
|
||||
entityId: link.entityId,
|
||||
capabilities: link.capabilities,
|
||||
householdId: link.householdId,
|
||||
expiresAt: link.expiresAt,
|
||||
createdBy: link.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,50 @@
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
export type ThemeId = "default" | "warm" | (string & {});
|
||||
|
||||
export const THEMES: ReadonlyArray<{ id: ThemeId; label: string }> = [
|
||||
{ id: "default", label: "Default" },
|
||||
{ id: "warm", label: "Warm" },
|
||||
export type Palette = "clay" | "indigo" | "sage" | "plum" | "ink";
|
||||
export type FontPair = "serif-sans" | "newsreader" | "fraunces" | "sans-only";
|
||||
export type Density = "compact" | "regular" | "comfy";
|
||||
export type DashLayout = "classic" | "split" | "glance";
|
||||
export type CalView = "month" | "week" | "day";
|
||||
export type NavStyle = "rail-desktop" | "compact-rail" | "top-nav" | "fab-only";
|
||||
|
||||
export const PALETTES: ReadonlyArray<{ id: Palette; label: string; hex: string }> = [
|
||||
{ id: "clay", label: "Clay", hex: "#B85C3C" },
|
||||
{ id: "indigo", label: "Indigo ink", hex: "#3E5B8A" },
|
||||
{ id: "sage", label: "Sage", hex: "#6F8B5E" },
|
||||
{ id: "plum", label: "Plum", hex: "#7B4F6E" },
|
||||
{ id: "ink", label: "Ink (mono)", hex: "#1F1B16" },
|
||||
];
|
||||
|
||||
export const FONT_PAIRS: ReadonlyArray<{ id: FontPair; label: string }> = [
|
||||
{ id: "serif-sans", label: "Source Serif + Inter" },
|
||||
{ id: "newsreader", label: "Newsreader + Inter" },
|
||||
{ id: "fraunces", label: "Fraunces + Inter" },
|
||||
{ id: "sans-only", label: "Inter only" },
|
||||
];
|
||||
|
||||
export const DENSITIES: ReadonlyArray<{ id: Density; label: string }> = [
|
||||
{ id: "compact", label: "Compact" },
|
||||
{ id: "regular", label: "Regular" },
|
||||
{ id: "comfy", label: "Comfy" },
|
||||
];
|
||||
|
||||
export const DASH_LAYOUTS: ReadonlyArray<{ id: DashLayout; label: string }> = [
|
||||
{ id: "classic", label: "Classic" },
|
||||
{ id: "split", label: "Split" },
|
||||
{ id: "glance", label: "Glance" },
|
||||
];
|
||||
|
||||
export const CAL_VIEWS: ReadonlyArray<{ id: CalView; label: string }> = [
|
||||
{ id: "month", label: "Month" },
|
||||
{ id: "week", label: "Week" },
|
||||
{ id: "day", label: "Day" },
|
||||
];
|
||||
|
||||
export const NAV_STYLES: ReadonlyArray<{ id: NavStyle; label: string }> = [
|
||||
{ id: "rail-desktop", label: "Sidebar" },
|
||||
{ id: "compact-rail", label: "Compact rail" },
|
||||
{ id: "top-nav", label: "Top nav" },
|
||||
{ id: "fab-only", label: "FAB only" },
|
||||
];
|
||||
|
||||
export const THEME_MODES: ReadonlyArray<{ id: ThemeMode; label: string }> = [
|
||||
@@ -12,5 +53,45 @@ export const THEME_MODES: ReadonlyArray<{ id: ThemeMode; label: string }> = [
|
||||
{ id: "system", label: "System" },
|
||||
];
|
||||
|
||||
export const VALID_THEME_IDS = new Set(THEMES.map((t) => t.id));
|
||||
export const VALID_THEME_MODES = new Set<string>(["light", "dark", "system"]);
|
||||
export const VALID_PALETTES = new Set<string>(PALETTES.map((p) => p.id));
|
||||
export const VALID_FONT_PAIRS = new Set<string>(FONT_PAIRS.map((p) => p.id));
|
||||
export const VALID_DENSITIES = new Set<string>(DENSITIES.map((p) => p.id));
|
||||
export const VALID_DASH_LAYOUTS = new Set<string>(DASH_LAYOUTS.map((p) => p.id));
|
||||
export const VALID_CAL_VIEWS = new Set<string>(CAL_VIEWS.map((p) => p.id));
|
||||
export const VALID_NAV_STYLES = new Set<string>(NAV_STYLES.map((p) => p.id));
|
||||
export const VALID_THEME_MODES = new Set<string>(THEME_MODES.map((p) => p.id));
|
||||
|
||||
export type ThemeState = {
|
||||
palette: Palette;
|
||||
mode: ThemeMode;
|
||||
fontPair: FontPair;
|
||||
density: Density;
|
||||
dashLayout: DashLayout;
|
||||
calView: CalView;
|
||||
navStyle: NavStyle;
|
||||
};
|
||||
|
||||
export const DEFAULT_THEME: ThemeState = {
|
||||
palette: "clay",
|
||||
mode: "system",
|
||||
fontPair: "serif-sans",
|
||||
density: "regular",
|
||||
dashLayout: "classic",
|
||||
calView: "month",
|
||||
navStyle: "rail-desktop",
|
||||
};
|
||||
|
||||
// Map our nav style preference to the data-nav attribute we set on <html>.
|
||||
// On mobile (handled by NavModeProvider) we override to "bottom" or "fab".
|
||||
export function navStyleToDataNav(style: NavStyle): "sidebar" | "rail" | "top" | "fab" {
|
||||
switch (style) {
|
||||
case "compact-rail":
|
||||
return "rail";
|
||||
case "top-nav":
|
||||
return "top";
|
||||
case "fab-only":
|
||||
return "fab";
|
||||
default:
|
||||
return "sidebar";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ShareButton } from "@/components/share-button";
|
||||
import type { CalView } from "@/modules/_core/themes";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -43,14 +44,38 @@ type EventDraft = {
|
||||
remindMinutesBefore: number | null;
|
||||
};
|
||||
|
||||
const DEFAULT_COLOR = "#2563eb";
|
||||
const DEFAULT_COLOR = "#B85C3C";
|
||||
|
||||
const VIEW_MAP: Record<CalView, string> = {
|
||||
month: "dayGridMonth",
|
||||
week: "timeGridWeek",
|
||||
day: "timeGridDay",
|
||||
};
|
||||
|
||||
function withAlpha(hex: string, alpha: number): string {
|
||||
// Accept #RGB / #RRGGBB / non-hex (return as-is for non-hex e.g. var(--))
|
||||
if (!hex.startsWith("#")) return hex;
|
||||
let h = hex.slice(1);
|
||||
if (h.length === 3)
|
||||
h = h
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("");
|
||||
if (h.length !== 6) return hex;
|
||||
const a = Math.round(Math.min(1, Math.max(0, alpha)) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `#${h}${a}`;
|
||||
}
|
||||
|
||||
export function CalendarShell({
|
||||
calendars,
|
||||
events,
|
||||
defaultView = "month",
|
||||
}: {
|
||||
calendars: CalendarDto[];
|
||||
events: CalendarEventDto[];
|
||||
defaultView?: CalView;
|
||||
}) {
|
||||
const [calendarRows, setCalendarRows] = useState(calendars);
|
||||
const [eventRows, setEventRows] = useState(events);
|
||||
@@ -380,15 +405,22 @@ export function CalendarShell({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="min-w-0 p-4">
|
||||
<section className="fc-skin min-w-0 p-4">
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
initialView={VIEW_MAP[defaultView]}
|
||||
headerToolbar={{
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
}}
|
||||
buttonText={{
|
||||
today: "Today",
|
||||
month: "Month",
|
||||
week: "Week",
|
||||
day: "Day",
|
||||
}}
|
||||
dayHeaderFormat={{ weekday: "short" }}
|
||||
selectable
|
||||
editable
|
||||
eventResizableFromStart
|
||||
@@ -396,19 +428,26 @@ export function CalendarShell({
|
||||
eventClick={openExistingEvent}
|
||||
eventDrop={moveEvent}
|
||||
eventResize={moveEvent}
|
||||
events={visibleEvents.map((event) => ({
|
||||
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"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-xl space-y-4 p-4">
|
||||
<header className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{data.calendarName}
|
||||
<>
|
||||
<ShareEyebrow>
|
||||
<CalendarIcon className="size-3" />
|
||||
Event
|
||||
</ShareEyebrow>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: "clamp(28px, 6vw, 38px)",
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: "-0.02em",
|
||||
color: "var(--ink)",
|
||||
margin: "0 0 8px",
|
||||
textWrap: "pretty",
|
||||
}}
|
||||
>
|
||||
{data.title}
|
||||
</h1>
|
||||
{data.calendarName && (
|
||||
<p
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
color: "var(--ink-soft)",
|
||||
margin: "0 0 28px",
|
||||
}}
|
||||
>
|
||||
On the {data.calendarName} calendar.
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold">{data.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatEventTime(data.startAt, data.endAt, data.allDay)}
|
||||
</p>
|
||||
</header>
|
||||
{data.location && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<MapPin className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span>{data.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid gap-3.5 mb-6"
|
||||
style={{ gridTemplateColumns: data.location ? "1fr 1fr" : "1fr" }}
|
||||
>
|
||||
<MiniDayCard date={start} time={time} />
|
||||
{data.location && <MiniMapCard name={data.location} />}
|
||||
</div>
|
||||
|
||||
<ShareDetailCard>
|
||||
<ShareRow label="When">
|
||||
{formatFullDate(start)}
|
||||
{!data.allDay && (
|
||||
<>
|
||||
{" · "}
|
||||
{formatTimeRange(data.startAt, data.endAt, data.allDay)}
|
||||
</>
|
||||
)}
|
||||
</ShareRow>
|
||||
{data.location && (
|
||||
<ShareRow label="Where">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-3.5 text-[var(--ink-mute)]" />
|
||||
{data.location}
|
||||
</span>
|
||||
</ShareRow>
|
||||
)}
|
||||
<ShareRow label="Calendar">{data.calendarName}</ShareRow>
|
||||
</ShareDetailCard>
|
||||
|
||||
{data.notes && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<StickyNote className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-pre-wrap">{data.notes}</p>
|
||||
</div>
|
||||
<p
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: 16,
|
||||
lineHeight: 1.65,
|
||||
color: "var(--ink-2)",
|
||||
background: "var(--paper-2)",
|
||||
borderRadius: 8,
|
||||
padding: "18px 22px",
|
||||
margin: "14px 0 24px",
|
||||
textWrap: "pretty",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{data.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarSharedView({ data }: { data: CalendarShareData }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4 p-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">{data.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Upcoming events — next 90 days</p>
|
||||
</header>
|
||||
{data.events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No upcoming events.</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-lg border bg-background">
|
||||
{data.events.map((event) => (
|
||||
<li key={event.id} className="flex flex-col gap-0.5 px-4 py-3">
|
||||
<span className="font-medium">{event.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatEventTime(event.startAt, event.endAt, event.allDay)}
|
||||
</span>
|
||||
{event.location && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="size-3" />
|
||||
{event.location}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<ShareEyebrow>
|
||||
<CalendarIcon className="size-3" />
|
||||
Calendar
|
||||
</ShareEyebrow>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: "clamp(28px, 6vw, 38px)",
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: "-0.02em",
|
||||
color: "var(--ink)",
|
||||
margin: "0 0 8px",
|
||||
textWrap: "pretty",
|
||||
}}
|
||||
>
|
||||
{data.name}
|
||||
</h1>
|
||||
<p className="serif" style={{ fontSize: 17, color: "var(--ink-soft)", margin: "0 0 28px" }}>
|
||||
Upcoming events — next 90 days.
|
||||
</p>
|
||||
|
||||
<ShareDetailCard>
|
||||
{data.events.length === 0 ? (
|
||||
<p className="muted text-[13.5px] py-2">No upcoming events.</p>
|
||||
) : (
|
||||
data.events.map((event) => {
|
||||
const start = new Date(event.startAt);
|
||||
return (
|
||||
<div key={event.id} className="list-row" style={{ padding: "10px 0" }}>
|
||||
<span className="dot" style={{ background: data.color ?? "var(--c-household)" }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-medium text-[var(--ink-2)]">{event.title}</div>
|
||||
<div className="muted tnum text-[12px] mt-0.5">
|
||||
{start.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
{!event.allDay && ` · ${formatTime(start)}`}
|
||||
{event.location && ` · ${event.location}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ShareDetailCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, { day: Date; events: typeof events }>();
|
||||
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 (
|
||||
<ul className="space-y-2">
|
||||
{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 (
|
||||
<li key={event.id} className="flex items-start gap-2 text-sm">
|
||||
<span className="mt-0.5 shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="font-medium leading-snug">{event.title}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...groups.values()].slice(0, 4).map((group, gi) => (
|
||||
<div key={gi}>
|
||||
<div className="eyebrow mb-1.5 flex items-baseline gap-2">
|
||||
<span>{dayLabel(group.day)}</span>
|
||||
<span className="text-[var(--ink-faint)] font-medium tracking-normal">
|
||||
{group.day.toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{group.events.map((event) => {
|
||||
const start = new Date(event.startAt);
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-start gap-2.5 px-2 py-1.5 rounded-md hover:bg-[var(--shade)]"
|
||||
>
|
||||
<span className="dot mt-2" style={{ background: "var(--c-household)" }} />
|
||||
<div className="min-w-[56px] tnum text-[var(--ink-mute)] text-[12px] mt-px">
|
||||
{event.allDay ? "all day" : formatTime(start)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13.5px] font-medium text-[var(--ink)] truncate">
|
||||
{event.title}
|
||||
</div>
|
||||
{event.location && (
|
||||
<div className="text-[11.5px] text-[var(--ink-mute)]">{event.location}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,28 +111,29 @@ async function MonthWidget({ config }: { config: unknown; ctx: WidgetContext })
|
||||
const monthName = now.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">{monthName}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="eyebrow">{monthName}</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No events this month</p>
|
||||
<p className="text-sm text-[var(--ink-mute)]">No events this month</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
<div className="flex flex-col">
|
||||
{events.slice(0, 10).map((event) => {
|
||||
const eventStart = new Date(event.startAt);
|
||||
const day = eventStart.getDate();
|
||||
return (
|
||||
<li key={event.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-5 shrink-0 text-center text-xs font-semibold text-muted-foreground">
|
||||
<div key={event.id} className="flex items-center gap-3 py-1.5 text-sm">
|
||||
<span className="w-6 shrink-0 text-center font-medium text-[var(--ink-mute)] tnum">
|
||||
{day}
|
||||
</span>
|
||||
<span className="truncate">{event.title}</span>
|
||||
</li>
|
||||
<span className="dot" style={{ background: "var(--c-household)" }} />
|
||||
<span className="truncate text-[var(--ink-2)]">{event.title}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{events.length > 10 && (
|
||||
<li className="text-xs text-muted-foreground">+{events.length - 10} more</li>
|
||||
<div className="text-xs text-[var(--ink-mute)] mt-1">+{events.length - 10} more</div>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const swipeStart = useRef<Record<string, number>>({});
|
||||
|
||||
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 (
|
||||
<div className="mx-auto grid w-full max-w-3xl gap-5 p-4">
|
||||
<div className="mx-auto grid w-full max-w-3xl gap-4">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex items-center gap-3 flex-1">
|
||||
<Input
|
||||
aria-label="List name"
|
||||
className="h-auto border-transparent px-0 text-2xl font-semibold shadow-none focus-visible:border-transparent focus-visible:ring-0"
|
||||
className="serif h-auto border-transparent !bg-transparent px-0 text-[22px] font-medium tracking-tight shadow-none focus-visible:border-transparent focus-visible:ring-0"
|
||||
value={list.name}
|
||||
onChange={(event) => setList({ ...list, name: event.target.value })}
|
||||
onBlur={commitListName}
|
||||
/>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{list.type} / {list.openCount} open / {list.doneCount} done
|
||||
</div>
|
||||
<span className="badge">
|
||||
{list.type} · {list.openCount} open
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShareButton entityType="lists.list" entityId={list.id} canWrite={true} />
|
||||
<Button variant="outline" size="sm" onClick={archiveCurrentList} disabled={isPending}>
|
||||
<Archive className="size-3.5" />
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
<ShareButton entityType="lists.list" entityId={list.id} canWrite={true} />
|
||||
<Button variant="outline" onClick={archiveCurrentList} disabled={isPending}>
|
||||
<Archive />
|
||||
Archive list
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitItem();
|
||||
}}
|
||||
<div
|
||||
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] shadow-[var(--shadow-1)] overflow-hidden"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
aria-label="Add item"
|
||||
autoFocus
|
||||
placeholder={list.type === "shopping" ? "Add milk, eggs, coffee..." : "Add a task..."}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<Button type="submit" disabled={!draft.trim() || isPending}>
|
||||
<Plus />
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
<form
|
||||
className="flex items-center gap-2 px-[14px] py-3 border-b-[0.5px]"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitItem();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 text-[var(--ink-mute)]" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
aria-label="Add item"
|
||||
autoFocus
|
||||
placeholder={list.type === "shopping" ? "Add to list…" : "Add a task…"}
|
||||
className="!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 px-0 h-7"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<span className="kbd">↵</span>
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
{list.items.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">Nothing here yet.</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{list.items.map((item) => (
|
||||
<li
|
||||
{openItems.length === 0 && doneItems.length === 0 && (
|
||||
<div className="muted px-[14px] py-8 text-center text-[13px]">Nothing here yet.</div>
|
||||
)}
|
||||
|
||||
{openItems.map((item) => (
|
||||
<ListItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onToggle={(done) => setItemDone(item, done)}
|
||||
onEdit={(text) => editItemText(item, text)}
|
||||
onCommit={() => commitItemText(item)}
|
||||
onRemove={() => removeItem(item)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{doneItems.length > 0 && (
|
||||
<>
|
||||
<div className="eyebrow px-[14px] pt-3 pb-[6px]">Done · {doneItems.length}</div>
|
||||
{doneItems.map((item) => (
|
||||
<ListItemRow
|
||||
key={item.id}
|
||||
className="grid grid-cols-[auto_1fr_auto] items-center gap-3 p-3"
|
||||
onPointerDown={(event) => {
|
||||
swipeStart.current[item.id] = event.clientX;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const start = swipeStart.current[item.id];
|
||||
if (start !== undefined && event.clientX - start < -60) removeItem(item);
|
||||
delete swipeStart.current[item.id];
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={`Complete ${item.text}`}
|
||||
type="checkbox"
|
||||
className="size-5 accent-primary"
|
||||
checked={item.done}
|
||||
onChange={(event) => setItemDone(item, event.target.checked)}
|
||||
/>
|
||||
<Input
|
||||
aria-label={`${item.text} text`}
|
||||
className={item.done ? "text-muted-foreground line-through" : ""}
|
||||
value={item.text}
|
||||
onChange={(event) => editItemText(item, event.target.value)}
|
||||
onBlur={() => commitItemText(item)}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<GripVertical className="size-4 text-muted-foreground" />
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Delete ${item.text}`}
|
||||
onClick={() => removeItem(item)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
item={item}
|
||||
onToggle={(done) => setItemDone(item, done)}
|
||||
onEdit={(text) => editItemText(item, text)}
|
||||
onCommit={() => commitItemText(item)}
|
||||
onRemove={() => removeItem(item)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListItemRow({
|
||||
item,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onCommit,
|
||||
onRemove,
|
||||
}: {
|
||||
item: ListItemDto;
|
||||
onToggle: (done: boolean) => void;
|
||||
onEdit: (text: string) => void;
|
||||
onCommit: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const swipeStart = useRef<number | null>(null);
|
||||
return (
|
||||
<div
|
||||
className={`list-row ${item.done ? "checked" : ""} group/row`}
|
||||
onPointerDown={(event) => {
|
||||
swipeStart.current = event.clientX;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const start = swipeStart.current;
|
||||
if (start !== null && event.clientX - start < -60) onRemove();
|
||||
swipeStart.current = null;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Complete ${item.text}`}
|
||||
aria-pressed={item.done}
|
||||
className={`checkbox ${item.done ? "on" : ""}`}
|
||||
onClick={() => onToggle(!item.done)}
|
||||
>
|
||||
{item.done && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
aria-label={`${item.text} text`}
|
||||
className="!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px]"
|
||||
value={item.text}
|
||||
onChange={(event) => onEdit(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Delete ${item.text}`}
|
||||
onClick={onRemove}
|
||||
className="opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,31 +24,50 @@ export function ListWidget({ initialItems }: { initialItems: WidgetItem[] }) {
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No open items</p>;
|
||||
return <p className="text-sm text-[var(--ink-mute)]">No open items</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
<div className="-mx-[14px] -my-[12px]">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div
|
||||
key={item.id}
|
||||
className={`list-row ${item.done ? "checked" : ""}`}
|
||||
style={{ padding: "8px 14px" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Complete ${item.text}`}
|
||||
className="size-4 shrink-0 accent-primary"
|
||||
checked={item.done}
|
||||
onChange={(e) => toggle(item, e.target.checked)}
|
||||
/>
|
||||
aria-pressed={item.done}
|
||||
className={`checkbox ${item.done ? "on" : ""}`}
|
||||
onClick={() => toggle(item, !item.done)}
|
||||
>
|
||||
{item.done && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
href={`/lists/${item.listId}`}
|
||||
className={`truncate hover:underline transition-colors ${
|
||||
item.done ? "text-muted-foreground line-through" : ""
|
||||
}`}
|
||||
className="row-text flex-1 text-[13.5px] truncate hover:text-[var(--ink)]"
|
||||
>
|
||||
{item.text}
|
||||
</Link>
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">{item.listName}</span>
|
||||
</li>
|
||||
<span className="ml-auto shrink-0 text-[11.5px] text-[var(--ink-mute)]">
|
||||
{item.listName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,13 +62,16 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-5xl gap-6 p-4">
|
||||
<div className="mx-auto grid w-full max-w-5xl gap-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Lists</h1>
|
||||
<p className="text-sm text-muted-foreground">Shopping, tasks, and whatever comes next.</p>
|
||||
<h2 className="serif text-[22px] tracking-tight">Lists</h2>
|
||||
<p className="muted text-[13px] mt-1">Shopping, tasks, and whatever comes next.</p>
|
||||
</div>
|
||||
<div className="grid gap-2 rounded-lg border bg-background p-3 sm:grid-cols-[140px_220px_auto]">
|
||||
<div
|
||||
className="grid gap-2 rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-3 sm:grid-cols-[140px_220px_auto]"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="new-list-type">Type</Label>
|
||||
<Input
|
||||
@@ -86,7 +89,7 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
||||
/>
|
||||
</div>
|
||||
<Button className="self-end" onClick={addList} disabled={!type || !name}>
|
||||
<Plus />
|
||||
<Plus className="size-3.5" />
|
||||
New list
|
||||
</Button>
|
||||
</div>
|
||||
@@ -95,9 +98,7 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
||||
<div className="grid gap-6">
|
||||
{grouped.map(([groupType, groupLists]) => (
|
||||
<section key={groupType} className="grid gap-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-normal text-muted-foreground">
|
||||
{groupType}
|
||||
</h2>
|
||||
<div className="eyebrow">{groupType}</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{groupLists.map((list) => (
|
||||
<ListCard key={list.id} list={list} onToggle={handleToggle} />
|
||||
@@ -120,25 +121,32 @@ function ListCard({
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card text-card-foreground">
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={expanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{list.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{list.openCount} open · {list.doneCount} done
|
||||
<div
|
||||
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] text-[var(--ink)] shadow-[var(--shadow-1)]"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
<div className="card-h">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
|
||||
aria-label={expanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<h3 className="serif text-[15px] truncate text-[var(--ink)] m-0 font-medium">
|
||||
{list.name}
|
||||
</h3>
|
||||
<div className="meta">
|
||||
{list.openCount} open · {list.doneCount} done
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/lists/${list.id}`}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="shrink-0 text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
|
||||
aria-label={`Open ${list.name}`}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
@@ -146,42 +154,55 @@ function ListCard({
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t">
|
||||
<div>
|
||||
{list.items.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">
|
||||
<p className="muted px-[14px] py-3 text-[13px]">
|
||||
{list.openCount === 0 ? "All done!" : "No items to show."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
<div>
|
||||
{list.items.map((item) => (
|
||||
<li key={item.id} className="flex items-center gap-3 px-4 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div
|
||||
key={item.id}
|
||||
className={`list-row ${item.done ? "checked" : ""}`}
|
||||
style={{ padding: "8px 14px" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Complete ${item.text}`}
|
||||
className="size-4 accent-primary shrink-0"
|
||||
checked={item.done}
|
||||
onChange={(e) => onToggle(list.id, item, e.target.checked)}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm truncate transition-colors ${
|
||||
item.done ? "text-muted-foreground line-through" : ""
|
||||
}`}
|
||||
aria-pressed={item.done}
|
||||
className={`checkbox ${item.done ? "on" : ""}`}
|
||||
onClick={() => onToggle(list.id, item, !item.done)}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
</li>
|
||||
{item.done && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<span className="row-text flex-1 text-[13.5px] truncate">{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
{list.openCount > list.items.filter((i) => !i.done).length && (
|
||||
<li className="px-4 py-2">
|
||||
<div className="px-[14px] py-2">
|
||||
<Link
|
||||
href={`/lists/${list.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-[12px] text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
|
||||
>
|
||||
+{list.openCount - list.items.filter((i) => !i.done).length} more — open list
|
||||
</Link>
|
||||
</li>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-xl space-y-4 p-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">{data.name}</h1>
|
||||
<p className="text-sm text-muted-foreground capitalize">{data.type}</p>
|
||||
</header>
|
||||
<>
|
||||
<ShareEyebrow>
|
||||
<ListChecks className="size-3" />
|
||||
List
|
||||
</ShareEyebrow>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: "clamp(28px, 6vw, 38px)",
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: "-0.02em",
|
||||
color: "var(--ink)",
|
||||
margin: "0 0 8px",
|
||||
textWrap: "pretty",
|
||||
}}
|
||||
>
|
||||
{data.name}
|
||||
</h1>
|
||||
<p className="muted text-[13px] capitalize mb-6">{data.type}</p>
|
||||
|
||||
{optimisticItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">This list is empty.</p>
|
||||
<p className="muted text-[13.5px]">This list is empty.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="rounded-[var(--r-md)] overflow-hidden"
|
||||
style={{
|
||||
background: "var(--card)",
|
||||
border: "0.5px solid var(--hair)",
|
||||
}}
|
||||
>
|
||||
{open.length > 0 && (
|
||||
<ul className="divide-y rounded-lg border bg-background">
|
||||
<>
|
||||
{open.map((item) => (
|
||||
<ItemRow key={item.id} item={item} canWrite={canWrite} onToggle={toggle} />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{done.length > 0 && (
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer select-none text-sm text-muted-foreground">
|
||||
{done.length} completed
|
||||
<summary
|
||||
className="cursor-pointer select-none eyebrow"
|
||||
style={{ padding: "12px 14px 8px", borderTop: "0.5px solid var(--hair)" }}
|
||||
>
|
||||
Done · {done.length}
|
||||
</summary>
|
||||
<ul className="mt-2 divide-y rounded-lg border bg-background">
|
||||
{done.map((item) => (
|
||||
<ItemRow key={item.id} item={item} canWrite={canWrite} onToggle={toggle} />
|
||||
))}
|
||||
</ul>
|
||||
{done.map((item) => (
|
||||
<ItemRow key={item.id} item={item} canWrite={canWrite} onToggle={toggle} />
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,25 +101,34 @@ function ItemRow({
|
||||
onToggle: (item: ListShareItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 px-4 py-3">
|
||||
{canWrite ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Complete ${item.text}`}
|
||||
className="size-5 accent-primary"
|
||||
checked={item.done}
|
||||
onChange={() => onToggle(item)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`size-5 shrink-0 rounded-sm border border-border ${item.done ? "bg-muted" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<span className={`text-sm ${item.done ? "text-muted-foreground line-through" : ""}`}>
|
||||
<div className={`list-row ${item.done ? "checked" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Complete ${item.text}`}
|
||||
aria-pressed={item.done}
|
||||
className={`checkbox ${item.done ? "on" : ""}`}
|
||||
disabled={!canWrite}
|
||||
onClick={() => onToggle(item)}
|
||||
>
|
||||
{item.done && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<span className="row-text flex-1 text-[13.5px]">
|
||||
{item.text}
|
||||
{item.qty && <span className="ml-1 text-xs text-muted-foreground">×{item.qty}</span>}
|
||||
{item.qty && <span className="ml-1 text-[11.5px] muted">×{item.qty}</span>}
|
||||
</span>
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,35 +64,40 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-6xl gap-5 p-4">
|
||||
<div className="mx-auto grid w-full max-w-6xl gap-4">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{currentNote ? currentNote.title : "New note"}</h1>
|
||||
<p className="text-sm text-muted-foreground">Markdown notes shared with the household.</p>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{pinned && <Pin className="size-3.5 text-[var(--accent)]" />}
|
||||
<h2 className="serif text-[24px] font-medium tracking-tight truncate">
|
||||
{currentNote ? currentNote.title : "New note"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentNote ? (
|
||||
<Button variant="outline" onClick={togglePinned} disabled={isPending}>
|
||||
{pinned ? <PinOff /> : <Pin />}
|
||||
{pinned ? "Unpin note" : "Pin note"}
|
||||
<Button variant="outline" size="sm" onClick={togglePinned} disabled={isPending}>
|
||||
{pinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />}
|
||||
{pinned ? "Unpin" : "Pin"}
|
||||
</Button>
|
||||
) : null}
|
||||
{currentNote ? <ShareButton entityType="notes.note" entityId={currentNote.id} /> : null}
|
||||
{currentNote ? (
|
||||
<Button variant="destructive" onClick={removeNote} disabled={isPending}>
|
||||
<Trash2 />
|
||||
Delete note
|
||||
<Button variant="destructive" size="sm" onClick={removeNote} disabled={isPending}>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={saveNote} disabled={!title.trim() || isPending}>
|
||||
<Save />
|
||||
Save note
|
||||
<Button size="sm" onClick={saveNote} disabled={!title.trim() || isPending}>
|
||||
<Save className="size-3.5" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(280px,420px)]">
|
||||
<section className="grid gap-4 rounded-lg border bg-background p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(280px,420px)]">
|
||||
<section
|
||||
className="grid gap-4 rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)]"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="note-title">Title</Label>
|
||||
<Input
|
||||
@@ -106,7 +111,13 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
|
||||
<textarea
|
||||
id="note-body"
|
||||
aria-label="Body"
|
||||
className="min-h-80 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm leading-6 outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="min-h-80 w-full rounded-[var(--r-md)] border-[0.5px] bg-transparent px-3 py-2 leading-[1.6] outline-none transition-colors placeholder:text-[var(--ink-faint)] focus-visible:border-[var(--ink)] focus-visible:ring-3 focus-visible:ring-[var(--ink)]/8"
|
||||
style={{
|
||||
fontFamily: "var(--serif)",
|
||||
fontSize: "15px",
|
||||
color: "var(--ink-2)",
|
||||
borderColor: "var(--hair-2)",
|
||||
}}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder="# Dinner ideas - Tacos - Soup"
|
||||
@@ -123,8 +134,11 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="rounded-lg border bg-card p-4 text-card-foreground">
|
||||
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Preview</h2>
|
||||
<aside
|
||||
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 text-[var(--ink)] shadow-[var(--shadow-1)]"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
<div className="eyebrow mb-3">Preview</div>
|
||||
<MarkdownPreview markdown={body} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -3,50 +3,119 @@ import { Plus, Pin } from "lucide-react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import type { NoteDto } from "../server/queries";
|
||||
|
||||
function relTime(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const days = Math.round(diffMs / 86400000);
|
||||
if (days <= 0) return "today";
|
||||
if (days === 1) return "yesterday";
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (days < 30) return `${Math.round(days / 7)}w ago`;
|
||||
return `${Math.round(days / 30)}mo ago`;
|
||||
}
|
||||
|
||||
export function NotesIndex({ notes }: { notes: NoteDto[] }) {
|
||||
const pinned = notes.filter((n) => n.pinned);
|
||||
const others = notes.filter((n) => !n.pinned);
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-5xl gap-6 p-4">
|
||||
<div className="mx-auto grid w-full max-w-5xl gap-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Notes</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h2 className="serif text-[22px] tracking-tight">Notes</h2>
|
||||
<p className="muted text-[13px] mt-1">
|
||||
Shared reminders, reference notes, and loose household details.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/notes/new" className={buttonVariants()}>
|
||||
<Plus />
|
||||
<Link href="/notes/new" className={buttonVariants({ size: "sm" })}>
|
||||
<Plus className="size-3.5" />
|
||||
New note
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<div className="rounded-lg border bg-background p-8 text-center text-sm text-muted-foreground">
|
||||
<div
|
||||
className="rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-8 text-center text-[13px] muted"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
No notes yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{notes.map((note) => (
|
||||
<Link
|
||||
key={note.id}
|
||||
href={`/notes/${note.id}`}
|
||||
className="grid min-h-32 gap-3 rounded-lg border bg-card p-4 text-card-foreground transition-colors hover:bg-muted"
|
||||
<>
|
||||
{pinned.length > 0 && (
|
||||
<div>
|
||||
<div className="eyebrow mb-2 flex items-center gap-1.5">
|
||||
<Pin className="size-3" /> Pinned
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pinned.map((note) => (
|
||||
<NoteCardLink key={note.id} note={note} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="eyebrow mb-2">All notes · {others.length}</div>
|
||||
<div
|
||||
className="rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] overflow-hidden"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="font-medium">{note.title}</h2>
|
||||
{note.pinned ? (
|
||||
<Pin aria-label="Pinned" className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="line-clamp-3 text-sm text-muted-foreground">
|
||||
{note.body || "No body text."}
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Updated {new Date(note.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{others.map((note, i) => (
|
||||
<Link
|
||||
key={note.id}
|
||||
href={`/notes/${note.id}`}
|
||||
className="flex gap-3 px-[14px] py-3 hover:bg-[var(--shade)]"
|
||||
style={{
|
||||
borderBottom: i === others.length - 1 ? "0" : "0.5px solid var(--hair)",
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="serif text-[15px] font-medium text-[var(--ink)] m-0">
|
||||
{note.title}
|
||||
</h4>
|
||||
<div
|
||||
className="muted text-[12.5px] mt-0.5 truncate"
|
||||
style={{ color: "var(--ink-mute)" }}
|
||||
>
|
||||
{note.body || "No body text."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="muted text-[11.5px] shrink-0 self-center">
|
||||
{relTime(note.updatedAt)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{others.length === 0 && (
|
||||
<div className="muted px-[14px] py-6 text-center text-[13px]">No notes match.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCardLink({ note }: { note: NoteDto }) {
|
||||
return (
|
||||
<Link href={`/notes/${note.id}`} className="note-card">
|
||||
{note.pinned && <Pin className="pin size-3" />}
|
||||
<h4 className="text-[15.5px]">{note.title}</h4>
|
||||
<p
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{note.body || "No body text."}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-[11.5px] muted">
|
||||
<span>{relTime(note.updatedAt)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,58 @@
|
||||
import { Pin } from "lucide-react";
|
||||
import { FileText, Pin } from "lucide-react";
|
||||
import type { NoteShareData } from "../server/share-queries";
|
||||
import { ShareEyebrow } from "@/components/share/share-eyebrow";
|
||||
|
||||
export function NoteSharedView({ data }: { data: NoteShareData }) {
|
||||
const updatedAt = new Date(data.updatedAt).toLocaleDateString(undefined, {
|
||||
const updated = new Date(data.updatedAt).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4 p-4">
|
||||
<header className="space-y-1">
|
||||
<>
|
||||
<ShareEyebrow>
|
||||
<FileText className="size-3" />
|
||||
Note
|
||||
</ShareEyebrow>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: "clamp(28px, 6vw, 38px)",
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: "-0.02em",
|
||||
color: "var(--ink)",
|
||||
margin: "0 0 8px",
|
||||
textWrap: "pretty",
|
||||
}}
|
||||
>
|
||||
{data.title}
|
||||
</h1>
|
||||
<p className="muted text-[13px] mb-6 inline-flex items-center gap-2">
|
||||
{data.pinned && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Pin className="size-3" />
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Pin className="size-3 text-[var(--accent)]" />
|
||||
Pinned
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold">{data.title}</h1>
|
||||
<p className="text-xs text-muted-foreground">Updated {updatedAt}</p>
|
||||
</header>
|
||||
{data.body && <p className="whitespace-pre-wrap text-sm leading-relaxed">{data.body}</p>}
|
||||
</div>
|
||||
{data.pinned && <span>·</span>}
|
||||
<span>Updated {updated}</span>
|
||||
</p>
|
||||
|
||||
{data.body && (
|
||||
<div
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: 16,
|
||||
lineHeight: 1.7,
|
||||
color: "var(--ink-2)",
|
||||
whiteSpace: "pre-wrap",
|
||||
textWrap: "pretty",
|
||||
}}
|
||||
>
|
||||
{data.body}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,21 +15,48 @@ async function NotesWidget({ config }: { config: unknown; ctx: WidgetContext })
|
||||
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-sm text-[var(--ink-mute)]">
|
||||
{parsed.filter === "pinned" ? "No pinned notes" : "No notes"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{notes.map((note) => (
|
||||
<li key={note.id} className="space-y-0.5">
|
||||
<p className="text-sm font-medium leading-snug">{note.title}</p>
|
||||
{note.body && <p className="line-clamp-2 text-xs text-muted-foreground">{note.body}</p>}
|
||||
</li>
|
||||
<a key={note.id} href={`/notes/${note.id}`} className="note-card">
|
||||
{parsed.filter === "pinned" && (
|
||||
<svg
|
||||
className="pin"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 4h6l1 6 3 3H5l3-3 1-6z" />
|
||||
</svg>
|
||||
)}
|
||||
<h4 className="text-[14px]">{note.title}</h4>
|
||||
{note.body && (
|
||||
<p
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{note.body}
|
||||
</p>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user