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,79 @@
import { MapPin, StickyNote } from "lucide-react";
import type { CalendarShareData, EventShareData } from "../server/share-queries";
function formatEventTime(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",
month: "long",
day: "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 }) {
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}
</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>
)}
{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>
)}
</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>
);
}