- Makes actorId nullable in activity_log (migration 0010) with onDelete: set null so audit history survives user deletion; adds logShareActivity for anonymous mutations - Adds resolveShareToken return of householdId for downstream validation - Adds renderSharedView to EntityTypeRegistration; each module implements loadForShare (no-session bare queries) and renderSharedView - Calendar: CalendarSharedView (upcoming 90-day events) and EventSharedView (title/time/location/notes) - Lists: ListSharedView (client component with optimistic toggles via toggleShareListItem server action in lists/server/share-actions.ts) - Notes: NoteSharedView (title + body + updated date) - /app/s/[token]/page.tsx: resolves token, dispatches to loadForShare + renderSharedView, friendly error for invalid/expired tokens, noindex - Middleware /s/* exemption confirmed present (no change needed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
105 lines
3.0 KiB
TypeScript
105 lines
3.0 KiB
TypeScript
"use client";
|
||
|
||
import { useOptimistic, useTransition } from "react";
|
||
import type { ListShareData, ListShareItem } from "../server/share-queries";
|
||
import { toggleShareListItem } from "../server/share-actions";
|
||
|
||
export function ListSharedView({
|
||
data,
|
||
canWrite,
|
||
token,
|
||
}: {
|
||
data: ListShareData;
|
||
canWrite: boolean;
|
||
token: string;
|
||
}) {
|
||
const [optimisticItems, setOptimisticItems] = useOptimistic(
|
||
data.items,
|
||
(current: ListShareItem[], { id, done }: { id: string; done: boolean }) =>
|
||
current.map((item) => (item.id === id ? { ...item, done } : item)),
|
||
);
|
||
const [, startTransition] = useTransition();
|
||
|
||
function toggle(item: ListShareItem) {
|
||
if (!canWrite) return;
|
||
const next = !item.done;
|
||
startTransition(async () => {
|
||
setOptimisticItems({ id: item.id, done: next });
|
||
await toggleShareListItem(token, item.id);
|
||
});
|
||
}
|
||
|
||
const open = optimisticItems.filter((i) => !i.done);
|
||
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>
|
||
|
||
{optimisticItems.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">This list is empty.</p>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{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>
|
||
<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>
|
||
</details>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ItemRow({
|
||
item,
|
||
canWrite,
|
||
onToggle,
|
||
}: {
|
||
item: ListShareItem;
|
||
canWrite: boolean;
|
||
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" : ""}`}>
|
||
{item.text}
|
||
{item.qty && (
|
||
<span className="ml-1 text-xs text-muted-foreground">×{item.qty}</span>
|
||
)}
|
||
</span>
|
||
</li>
|
||
);
|
||
}
|