Implement share viewer (task 31)

- 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>
This commit is contained in:
ginnoir
2026-05-06 14:26:38 -05:00
co-authored by Claude Sonnet 4.6
parent d5deee9a46
commit 39aa5704f9
18 changed files with 558 additions and 5 deletions
@@ -0,0 +1,104 @@
"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>
);
}