Apply paper-and-ink design system across all surfaces
Release / build-and-push (push) Has been cancelled

Replaces the generic shadcn/ui gray theme + horizontal top-bar shell with
the paper-and-ink language from the Claude Design handoff bundle: warm
off-white paper, near-black ink, Source Serif 4 + Inter, hairline borders,
muted ink accents (clay/indigo/sage/plum/ochre) used functionally for
calendars and share scopes.

Theme switcher expanded from 2 dimensions (theme × mode) to 7: palette ×
mode × fontPair × density × dashLayout × calView × navStyle. All exposed
in Settings → Appearance and persisted on the users row. Pre-paint script
applies all four data-* attributes from localStorage so reload doesn't
flash.

App shell restructured to a CSS-grid driven by data-nav on <html>: sidebar
on desktop, bottom-nav + FAB under 760px. Four desktop nav modes wired
(sidebar/rail/top/fab-only). Topbar gets a search-→-CommandPalette button,
notification bell, "+ New" quick-add, avatar.

Dashboard, calendar, lists, notes, settings, login, public share viewer,
and quick-add sheet all reskinned. Dashboard editor gains a Preset menu
(classic/split/glance) that fills the layout from the registered widgets.
FullCalendar wrapped in .fc-skin and inherits all paper-and-ink tokens via
CSS variable overrides. Public share viewer (/s/<token>) rebuilt around
ShareFrame: expiration banner, brand strip, eyebrow chip, 38px serif
title, mini-day + mini-map cards, share-rows.

Schema: drops users.theme; adds theme_palette, theme_font_pair,
theme_density, theme_dash_layout, theme_cal_view, theme_nav_style with
defaults that match the design (clay / serif-sans / regular / classic /
month / rail-desktop). Migration 0014_paper_ink_theme.

Middleware sets x-pathname so the AppShell server component can render
bare for /s/* and /login without a route-group refactor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-07 01:03:21 -05:00
co-authored by Claude Opus 4.7
parent e130cda6c5
commit 9612a54e52
61 changed files with 4173 additions and 894 deletions
+10 -8
View File
@@ -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>
);
}
+6 -1
View File
@@ -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),
+4
View File
@@ -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,
};
}
+87 -6
View File
@@ -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>
+145 -55
View File
@@ -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>
</>
);
}
+72 -29
View File
@@ -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>
);
+135 -76
View File
@@ -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>
);
}
+34 -15
View File
@@ -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>
);
}
+65 -44
View File
@@ -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>
)}
+67 -35
View File
@@ -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>
);
}
+32 -18
View File
@@ -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&#10;&#10;- Tacos&#10;- 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>
+96 -27
View File
@@ -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>
);
}
+43 -11
View File
@@ -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>
)}
</>
);
}
+34 -7
View File
@@ -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>
);
}