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:
co-authored by
Claude Sonnet 4.6
parent
d5deee9a46
commit
39aa5704f9
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||
import { z } from "zod";
|
||||
import { listLists, listWidgetItems, searchItems, searchLists } from "./server/queries";
|
||||
import { loadListForShare, type ListShareData } from "./server/share-queries";
|
||||
import { ListSharedView } from "./components/shared-view";
|
||||
|
||||
const listIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]);
|
||||
|
||||
@@ -54,6 +56,14 @@ const manifest: ModuleManifest = {
|
||||
share: { canShare: true, defaultCapabilities: ["read", "write"] },
|
||||
search: { search: searchLists },
|
||||
resolveUrl: (id) => `/lists/${id}`,
|
||||
loadForShare: (id) => loadListForShare(id),
|
||||
renderSharedView: ({ data, capabilities, token }) => (
|
||||
<ListSharedView
|
||||
data={data as ListShareData}
|
||||
canWrite={capabilities.write}
|
||||
token={token}
|
||||
/>
|
||||
),
|
||||
renderActivity: (entry) => {
|
||||
const name = entry.payload?.name as string | undefined;
|
||||
if (entry.action === "create") return `Created list${name ? ` "${name}"` : ""}`;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { resolveShareToken } from "@/modules/_core/share";
|
||||
import { logShareActivity } from "@/modules/_core/activity";
|
||||
import { listItems, lists } from "../schema";
|
||||
import { notifyListChanged } from "./realtime";
|
||||
|
||||
export async function toggleShareListItem(rawToken: string, itemId: string): Promise<void> {
|
||||
z.string().min(1).parse(rawToken);
|
||||
z.string().uuid().parse(itemId);
|
||||
|
||||
const resolved = await resolveShareToken(rawToken);
|
||||
if (!resolved || resolved.entityType !== "lists.list" || !resolved.capabilities.write) {
|
||||
throw new Error("Invalid or read-only share token");
|
||||
}
|
||||
|
||||
const listId = resolved.entityId;
|
||||
|
||||
const [item] = await db
|
||||
.select({
|
||||
id: listItems.id,
|
||||
done: listItems.done,
|
||||
text: listItems.text,
|
||||
listId: listItems.listId,
|
||||
})
|
||||
.from(listItems)
|
||||
.innerJoin(lists, eq(listItems.listId, lists.id))
|
||||
.where(
|
||||
and(
|
||||
eq(listItems.id, itemId),
|
||||
eq(listItems.listId, listId),
|
||||
eq(lists.householdId, resolved.householdId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!item) throw new Error("Item not found");
|
||||
|
||||
const done = !item.done;
|
||||
await db
|
||||
.update(listItems)
|
||||
.set({ done, updatedAt: new Date() })
|
||||
.where(eq(listItems.id, itemId));
|
||||
|
||||
await logShareActivity({
|
||||
householdId: resolved.householdId,
|
||||
entityType: "lists.item",
|
||||
entityId: itemId,
|
||||
action: "share.toggle",
|
||||
payload: { done, text: item.text },
|
||||
});
|
||||
|
||||
revalidatePath(`/s/${rawToken}`);
|
||||
await notifyListChanged(listId);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { listItems, lists } from "../schema";
|
||||
|
||||
export type ListShareItem = {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
qty: string | null;
|
||||
notes: string | null;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type ListShareData = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
householdId: string;
|
||||
items: ListShareItem[];
|
||||
};
|
||||
|
||||
export async function loadListForShare(id: string): Promise<ListShareData | null> {
|
||||
const [list] = await db
|
||||
.select({
|
||||
id: lists.id,
|
||||
name: lists.name,
|
||||
type: lists.type,
|
||||
householdId: lists.householdId,
|
||||
})
|
||||
.from(lists)
|
||||
.where(eq(lists.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!list) return null;
|
||||
|
||||
const items = await db
|
||||
.select({
|
||||
id: listItems.id,
|
||||
text: listItems.text,
|
||||
done: listItems.done,
|
||||
qty: listItems.qty,
|
||||
notes: listItems.notes,
|
||||
position: listItems.position,
|
||||
})
|
||||
.from(listItems)
|
||||
.where(eq(listItems.listId, id))
|
||||
.orderBy(asc(listItems.done), asc(listItems.position), asc(listItems.createdAt));
|
||||
|
||||
return { ...list, items };
|
||||
}
|
||||
Reference in New Issue
Block a user