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
@@ -23,6 +23,7 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
|
|||||||
- **21 — Quick-add registry**. Added `url: string` to `QuickAddAction` type (action is now optional). Added `getQuickAdds()` / `SerializedQuickAddItem` to registry (strips non-serializable `action` fn before crossing server→client boundary). Updated all three module manifests with navigation URLs. Built `QuickAddProvider` (context + cmd+k global shortcut), `QuickAddFab` (opens sheet, replaces plain button in dashboard), `QuickAddSheet` (bottom drawer / desktop popover grouped by module), and `CommandPalette` (cmdk-powered modal with arrow + enter + esc keyboard nav). Provider in root layout receives actions from `getQuickAdds()` at render time — adding a module's `quickAdds` automatically appears in both surfaces. Also added `.claude/**` to ESLint ignores to prevent stale worktree build artifacts from failing lint. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
- **21 — Quick-add registry**. Added `url: string` to `QuickAddAction` type (action is now optional). Added `getQuickAdds()` / `SerializedQuickAddItem` to registry (strips non-serializable `action` fn before crossing server→client boundary). Updated all three module manifests with navigation URLs. Built `QuickAddProvider` (context + cmd+k global shortcut), `QuickAddFab` (opens sheet, replaces plain button in dashboard), `QuickAddSheet` (bottom drawer / desktop popover grouped by module), and `CommandPalette` (cmdk-powered modal with arrow + enter + esc keyboard nav). Provider in root layout receives actions from `getQuickAdds()` at render time — adding a module's `quickAdds` automatically appears in both surfaces. Also added `.claude/**` to ESLint ignores to prevent stale worktree build artifacts from failing lint. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||||
- **22 — Activity log**. Added `activity_log` table to `_core/schema.ts` with index on `(household_id, created_at desc)`. Migration `0008_activity_log.sql` applied. `logActivity()` server function in `_core/activity.ts` reads current session and inserts a row. Added `ActivityLogEntry` type and optional `renderActivity?(entry): string` to `EntityTypeRegistration` in `_core/module.ts`. All three module manifests implement `renderActivity` for each entity type (human-readable, no hardcoded branches in the widget). Replaced `core.activity` widget stub with a real async server component that queries the last 20 rows via `getEntityType(entry.entityType)?.renderActivity(entry)`. Wired `logActivity()` into every create/update/delete in calendar, lists, and notes server actions. Also added `text` to `getAuthorizedItem` select so toggle/delete log the item text. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
- **22 — Activity log**. Added `activity_log` table to `_core/schema.ts` with index on `(household_id, created_at desc)`. Migration `0008_activity_log.sql` applied. `logActivity()` server function in `_core/activity.ts` reads current session and inserts a row. Added `ActivityLogEntry` type and optional `renderActivity?(entry): string` to `EntityTypeRegistration` in `_core/module.ts`. All three module manifests implement `renderActivity` for each entity type (human-readable, no hardcoded branches in the widget). Replaced `core.activity` widget stub with a real async server component that queries the last 20 rows via `getEntityType(entry.entityType)?.renderActivity(entry)`. Wired `logActivity()` into every create/update/delete in calendar, lists, and notes server actions. Also added `text` to `getAuthorizedItem` select so toggle/delete log the item text. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||||
- **30 — Share-link service**. Added `share_links` table to `_core/schema.ts` + migration `0009_share_links.sql`. Created `_core/share.ts` with `createShareLink`, `resolveShareToken`, `revokeShareLink`, and `getActiveShareLinks`. Token is 32 random bytes (URL-safe base64), stored as SHA-256 hash — raw token only returned at creation. `createShareLink` guards that the entity type is registered with `canShare === true`. `resolveShareToken` returns null for expired or revoked tokens. All three functions exported from `_core/index.ts`. `/settings` page gained a Share links card: lists active links (entity label, read/write capabilities, expiry) with a Revoke button per link (server action in `settings/actions.ts`). `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
- **30 — Share-link service**. Added `share_links` table to `_core/schema.ts` + migration `0009_share_links.sql`. Created `_core/share.ts` with `createShareLink`, `resolveShareToken`, `revokeShareLink`, and `getActiveShareLinks`. Token is 32 random bytes (URL-safe base64), stored as SHA-256 hash — raw token only returned at creation. `createShareLink` guards that the entity type is registered with `canShare === true`. `resolveShareToken` returns null for expired or revoked tokens. All three functions exported from `_core/index.ts`. `/settings` page gained a Share links card: lists active links (entity label, read/write capabilities, expiry) with a Revoke button per link (server action in `settings/actions.ts`). `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||||
|
- **31 — Public share viewer**. Made `actorId` nullable in `activity_log` (migration `0010_nullable_actor_id.sql`, `onDelete: "set null"`) for anonymous share-page mutations. Added `logShareActivity` to `_core/activity.ts` (no session, explicit `householdId`). Added `householdId` to `resolveShareToken` return. Added `renderSharedView` to `EntityTypeRegistration` type. Each module implements `loadForShare` (bare DB queries, no session) and `renderSharedView`: calendar shows upcoming 90-day events or single-event details, lists shows items with optional toggle, notes shows title + body. `toggleShareListItem` server action lives in `lists/server/share-actions.ts` — validates token write capability, verifies item→list→household chain, logs `share.toggle` with `actorId = null`. `/app/s/[token]/page.tsx` resolves token, dispatches to `loadForShare` + `renderSharedView`, returns friendly error for invalid/expired tokens, sets `noindex`. Middleware `/s/*` exemption confirmed present. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||||
|
|
||||||
## Next up
|
## Next up
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "activity_log" ALTER COLUMN "actor_id" DROP NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "activity_log" DROP CONSTRAINT "activity_log_actor_id_users_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { resolveShareToken } from "@/modules/_core/share";
|
||||||
|
import { getEntityType } from "@/modules/_core/registry";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SharePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ token: string }>;
|
||||||
|
}) {
|
||||||
|
const { token } = await params;
|
||||||
|
|
||||||
|
const resolved = await resolveShareToken(token);
|
||||||
|
if (!resolved) return <ShareError />;
|
||||||
|
|
||||||
|
const entityReg = getEntityType(resolved.entityType);
|
||||||
|
if (!entityReg?.loadForShare || !entityReg.renderSharedView) {
|
||||||
|
return <ShareError message="This content type cannot be shared." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await entityReg.loadForShare(resolved.entityId);
|
||||||
|
if (!data) return <ShareError />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="border-b bg-background px-4 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Shared via famapp
|
||||||
|
{resolved.capabilities.write ? " · You can edit this" : " · View only"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{entityReg.renderSharedView({ data, capabilities: resolved.capabilities, token })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareError({ message }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||||
|
<h1 className="text-xl font-semibold">Link not found</h1>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
{message ??
|
||||||
|
"This share link may have expired or been revoked. Ask the sender for a new link."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,3 +18,20 @@ export async function logActivity(input: {
|
|||||||
payload: input.payload ?? null,
|
payload: input.payload ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function logShareActivity(input: {
|
||||||
|
householdId: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
action: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
await db.insert(activityLog).values({
|
||||||
|
householdId: input.householdId,
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
actorId: null,
|
||||||
|
action: input.action,
|
||||||
|
payload: input.payload ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ export type {
|
|||||||
} from "./module";
|
} from "./module";
|
||||||
export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds } from "./registry";
|
export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds } from "./registry";
|
||||||
export type { QuickAddItem, SerializedQuickAddItem } from "./registry";
|
export type { QuickAddItem, SerializedQuickAddItem } from "./registry";
|
||||||
export { logActivity } from "./activity";
|
export { logActivity, logShareActivity } from "./activity";
|
||||||
export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
|
export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
|
||||||
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type ActivityLogEntry = {
|
|||||||
householdId: string;
|
householdId: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
actorId: string;
|
actorId: string | null;
|
||||||
action: string;
|
action: string;
|
||||||
payload: Record<string, unknown> | null;
|
payload: Record<string, unknown> | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -69,6 +69,11 @@ export type EntityTypeRegistration = {
|
|||||||
search?: SearchAdapter;
|
search?: SearchAdapter;
|
||||||
resolveUrl: (id: string) => string;
|
resolveUrl: (id: string) => string;
|
||||||
loadForShare?: (id: string) => Promise<unknown>;
|
loadForShare?: (id: string) => Promise<unknown>;
|
||||||
|
renderSharedView?: (props: {
|
||||||
|
data: unknown;
|
||||||
|
capabilities: { read: boolean; write: boolean };
|
||||||
|
token: string;
|
||||||
|
}) => ReactNode;
|
||||||
renderActivity?: (entry: ActivityLogEntry) => string;
|
renderActivity?: (entry: ActivityLogEntry) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,7 @@ export const activityLog = pgTable(
|
|||||||
entityType: text("entity_type").notNull(),
|
entityType: text("entity_type").notNull(),
|
||||||
entityId: uuid("entity_id").notNull(),
|
entityId: uuid("entity_id").notNull(),
|
||||||
actorId: uuid("actor_id")
|
actorId: uuid("actor_id")
|
||||||
.notNull()
|
.references(() => users.id, { onDelete: "set null" }),
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
action: text("action").notNull(),
|
action: text("action").notNull(),
|
||||||
payload: jsonb("payload").$type<Record<string, unknown> | null>(),
|
payload: jsonb("payload").$type<Record<string, unknown> | null>(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ export async function createShareLink(
|
|||||||
|
|
||||||
export async function resolveShareToken(
|
export async function resolveShareToken(
|
||||||
rawToken: string,
|
rawToken: string,
|
||||||
): Promise<{ entityType: string; entityId: string; capabilities: ShareLinkCapabilities } | null> {
|
): Promise<{
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
capabilities: ShareLinkCapabilities;
|
||||||
|
householdId: string;
|
||||||
|
} | null> {
|
||||||
const tokenHash = hashToken(rawToken);
|
const tokenHash = hashToken(rawToken);
|
||||||
|
|
||||||
const [link] = await db
|
const [link] = await db
|
||||||
@@ -78,6 +83,7 @@ export async function resolveShareToken(
|
|||||||
entityType: link.entityType,
|
entityType: link.entityType,
|
||||||
entityId: link.entityId,
|
entityId: link.entityId,
|
||||||
capabilities: link.capabilities,
|
capabilities: link.capabilities,
|
||||||
|
householdId: link.householdId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { listCalendars, listEvents, searchCalendars, searchEvents } from "./server/queries";
|
import { listCalendars, listEvents, searchCalendars, searchEvents } from "./server/queries";
|
||||||
|
import {
|
||||||
|
loadCalendarForShare,
|
||||||
|
loadEventForShare,
|
||||||
|
type CalendarShareData,
|
||||||
|
type EventShareData,
|
||||||
|
} from "./server/share-queries";
|
||||||
|
import { CalendarSharedView, EventSharedView } from "./components/shared-view";
|
||||||
|
|
||||||
const calendarIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]);
|
const calendarIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]);
|
||||||
|
|
||||||
@@ -101,6 +108,8 @@ const manifest: ModuleManifest = {
|
|||||||
share: { canShare: true, defaultCapabilities: ["read"] },
|
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||||
search: { search: searchCalendars },
|
search: { search: searchCalendars },
|
||||||
resolveUrl: (id) => `/calendar?id=${id}`,
|
resolveUrl: (id) => `/calendar?id=${id}`,
|
||||||
|
loadForShare: (id) => loadCalendarForShare(id),
|
||||||
|
renderSharedView: ({ data }) => <CalendarSharedView data={data as CalendarShareData} />,
|
||||||
renderActivity: (entry) => {
|
renderActivity: (entry) => {
|
||||||
const name = entry.payload?.name as string | undefined;
|
const name = entry.payload?.name as string | undefined;
|
||||||
if (entry.action === "create") return `Created calendar${name ? ` "${name}"` : ""}`;
|
if (entry.action === "create") return `Created calendar${name ? ` "${name}"` : ""}`;
|
||||||
@@ -115,6 +124,8 @@ const manifest: ModuleManifest = {
|
|||||||
reminder: { canRemind: true },
|
reminder: { canRemind: true },
|
||||||
search: { search: searchEvents },
|
search: { search: searchEvents },
|
||||||
resolveUrl: (id) => `/calendar/events/${id}`,
|
resolveUrl: (id) => `/calendar/events/${id}`,
|
||||||
|
loadForShare: (id) => loadEventForShare(id),
|
||||||
|
renderSharedView: ({ data }) => <EventSharedView data={data as EventShareData} />,
|
||||||
renderActivity: (entry) => {
|
renderActivity: (entry) => {
|
||||||
const title = entry.payload?.title as string | undefined;
|
const title = entry.payload?.title as string | undefined;
|
||||||
if (entry.action === "create") return `Created event${title ? ` "${title}"` : ""}`;
|
if (entry.action === "create") return `Created event${title ? ` "${title}"` : ""}`;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { and, asc, eq, gte, lte } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { calendarEvents, calendars } from "../schema";
|
||||||
|
|
||||||
|
export type EventSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
allDay: boolean;
|
||||||
|
location: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarShareData = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
events: EventSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventShareData = EventSummary & { calendarName: string };
|
||||||
|
|
||||||
|
export async function loadCalendarForShare(id: string): Promise<CalendarShareData | null> {
|
||||||
|
const [calendar] = await db
|
||||||
|
.select({ id: calendars.id, name: calendars.name, color: calendars.color })
|
||||||
|
.from(calendars)
|
||||||
|
.where(eq(calendars.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!calendar) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: calendarEvents.id,
|
||||||
|
title: calendarEvents.title,
|
||||||
|
startAt: calendarEvents.startAt,
|
||||||
|
endAt: calendarEvents.endAt,
|
||||||
|
allDay: calendarEvents.allDay,
|
||||||
|
location: calendarEvents.location,
|
||||||
|
notes: calendarEvents.notes,
|
||||||
|
})
|
||||||
|
.from(calendarEvents)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(calendarEvents.calendarId, id),
|
||||||
|
gte(calendarEvents.startAt, now),
|
||||||
|
lte(calendarEvents.startAt, end),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(calendarEvents.startAt));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...calendar,
|
||||||
|
events: rows.map((e) => ({
|
||||||
|
...e,
|
||||||
|
startAt: e.startAt.toISOString(),
|
||||||
|
endAt: e.endAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEventForShare(id: string): Promise<EventShareData | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: calendarEvents.id,
|
||||||
|
title: calendarEvents.title,
|
||||||
|
startAt: calendarEvents.startAt,
|
||||||
|
endAt: calendarEvents.endAt,
|
||||||
|
allDay: calendarEvents.allDay,
|
||||||
|
location: calendarEvents.location,
|
||||||
|
notes: calendarEvents.notes,
|
||||||
|
calendarName: calendars.name,
|
||||||
|
})
|
||||||
|
.from(calendarEvents)
|
||||||
|
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||||
|
.where(eq(calendarEvents.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
startAt: row.startAt.toISOString(),
|
||||||
|
endAt: row.endAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { listLists, listWidgetItems, searchItems, searchLists } from "./server/queries";
|
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())]);
|
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"] },
|
share: { canShare: true, defaultCapabilities: ["read", "write"] },
|
||||||
search: { search: searchLists },
|
search: { search: searchLists },
|
||||||
resolveUrl: (id) => `/lists/${id}`,
|
resolveUrl: (id) => `/lists/${id}`,
|
||||||
|
loadForShare: (id) => loadListForShare(id),
|
||||||
|
renderSharedView: ({ data, capabilities, token }) => (
|
||||||
|
<ListSharedView
|
||||||
|
data={data as ListShareData}
|
||||||
|
canWrite={capabilities.write}
|
||||||
|
token={token}
|
||||||
|
/>
|
||||||
|
),
|
||||||
renderActivity: (entry) => {
|
renderActivity: (entry) => {
|
||||||
const name = entry.payload?.name as string | undefined;
|
const name = entry.payload?.name as string | undefined;
|
||||||
if (entry.action === "create") return `Created list${name ? ` "${name}"` : ""}`;
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Pin } from "lucide-react";
|
||||||
|
import type { NoteShareData } from "../server/share-queries";
|
||||||
|
|
||||||
|
export function NoteSharedView({ data }: { data: NoteShareData }) {
|
||||||
|
const updatedAt = 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">
|
||||||
|
{data.pinned && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Pin className="size-3" />
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { listWidgetNotes, searchNotes } from "./server/queries";
|
import { listWidgetNotes, searchNotes } from "./server/queries";
|
||||||
|
import { loadNoteForShare, type NoteShareData } from "./server/share-queries";
|
||||||
|
import { NoteSharedView } from "./components/shared-view";
|
||||||
|
|
||||||
const notesWidgetConfigSchema = z.object({
|
const notesWidgetConfigSchema = z.object({
|
||||||
filter: z.enum(["pinned", "all"]),
|
filter: z.enum(["pinned", "all"]),
|
||||||
@@ -45,6 +47,8 @@ const manifest: ModuleManifest = {
|
|||||||
reminder: { canRemind: true },
|
reminder: { canRemind: true },
|
||||||
search: { search: searchNotes },
|
search: { search: searchNotes },
|
||||||
resolveUrl: (id) => `/notes/${id}`,
|
resolveUrl: (id) => `/notes/${id}`,
|
||||||
|
loadForShare: (id) => loadNoteForShare(id),
|
||||||
|
renderSharedView: ({ data }) => <NoteSharedView data={data as NoteShareData} />,
|
||||||
renderActivity: (entry) => {
|
renderActivity: (entry) => {
|
||||||
const title = entry.payload?.title as string | undefined;
|
const title = entry.payload?.title as string | undefined;
|
||||||
if (entry.action === "create") return `Created note${title ? ` "${title}"` : ""}`;
|
if (entry.action === "create") return `Created note${title ? ` "${title}"` : ""}`;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { notes } from "../schema";
|
||||||
|
|
||||||
|
export type NoteShareData = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
pinned: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadNoteForShare(id: string): Promise<NoteShareData | null> {
|
||||||
|
const [note] = await db
|
||||||
|
.select({
|
||||||
|
id: notes.id,
|
||||||
|
title: notes.title,
|
||||||
|
body: notes.body,
|
||||||
|
pinned: notes.pinned,
|
||||||
|
updatedAt: notes.updatedAt,
|
||||||
|
})
|
||||||
|
.from(notes)
|
||||||
|
.where(eq(notes.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!note) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: note.id,
|
||||||
|
title: note.title,
|
||||||
|
body: note.body,
|
||||||
|
pinned: note.pinned,
|
||||||
|
updatedAt: note.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user