Refine workspaces and event publishing; harden uploads and email delivery

This commit is contained in:
2026-09-09 15:44:00 -04:00
parent f5702caaea
commit 574f29a68e
93 changed files with 2885 additions and 535 deletions
+10 -33
View File
@@ -1,39 +1,16 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { auth } from "@/server/auth";
import { getPlatformRole } from "@/server/roles";
import { Button } from "@/components/ui/button";
import { createServerCaller } from "@/trpc/server";
import { BackendShell } from "@/components/backend-shell";
import { DashboardTabBar } from "@/components/dashboard-tab-bar";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/sign-in?callbackURL=/admin");
const role = await getPlatformRole(session.user.id);
if (!role) redirect("/dashboard");
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const caller = await createServerCaller();
const viewer = await caller.viewer.me();
if (!viewer.session) redirect("/sign-in?callbackURL=/admin");
if (!viewer.platformRole) redirect("/dashboard");
return (
<>
<main className="page-pad mx-auto w-full max-w-6xl py-6 pb-24 sm:py-10 sm:pb-10">
<div className="mb-6 hidden items-center gap-1 sm:flex">
<Button asChild variant="ghost" size="sm">
<Link href="/admin">Overview</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/admin/settings">Settings</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard">Dashboard</Link>
</Button>
</div>
<div className="reveal">{children}</div>
</main>
<DashboardTabBar showAdmin area="admin" />
</>
<BackendShell area="platform" groups={viewer.groups} activeGroupId={viewer.activeGroupId} showAdmin>
<div className="reveal">{children}</div>
</BackendShell>
);
}
+5 -3
View File
@@ -1,5 +1,7 @@
"use client";
import { NativeSelect } from "@/components/ui/native-select";
import { useState } from "react";
import { toast } from "sonner";
import { api } from "@/trpc/react";
@@ -78,8 +80,8 @@ export function PlatformCodes({
) : null}
{groups.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<select
className="rounded-md border bg-background px-2 py-1.5 text-sm"
<NativeSelect
value={groupId}
onChange={(event) => setGroupId(event.target.value)}
>
@@ -88,7 +90,7 @@ export function PlatformCodes({
{group.name}
</option>
))}
</select>
</NativeSelect>
<Button
variant="secondary"
disabled={!groupId}
+13 -9
View File
@@ -1,5 +1,7 @@
"use client";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useState } from "react";
import { toast } from "sonner";
import type { PlatformRole } from "@album/contracts";
@@ -55,25 +57,27 @@ export function PlatformUsers() {
</div>
<div className="flex items-center gap-2">
{row.platformRole ? (
<Badge variant="secondary">{row.platformRole}</Badge>
<Badge variant="secondary" className="capitalize">{row.platformRole.replaceAll("_", " ")}</Badge>
) : null}
<select
className="rounded-md border bg-background px-2 py-1.5 text-sm"
<Select
value={row.platformRole ?? "none"}
onChange={(event) => {
const value = event.target.value as PlatformRole | "none";
onValueChange={(selected) => {
const value = selected as PlatformRole | "none";
setRole.mutate({
userId: row.id,
role: value === "none" ? null : value,
});
}}
>
<SelectTrigger aria-label={`Platform role for ${row.name}`}><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>
{roles.map((role) => (
<option key={role} value={role}>
{role}
</option>
<SelectItem key={role} value={role} className="capitalize">
{role.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase())}
</SelectItem>
))}
</select>
</SelectGroup></SelectContent>
</Select>
</div>
</li>
))}
@@ -1,5 +1,7 @@
"use client";
import { NativeSelect } from "@/components/ui/native-select";
import { useState } from "react";
import { toast } from "sonner";
import type { EventCreatePolicy } from "@album/contracts";
@@ -61,9 +63,9 @@ export function DeploymentSettingsForm({
</Field>
<Field>
<FieldLabel htmlFor="policy">Event creation</FieldLabel>
<select
<NativeSelect
id="policy"
className="rounded-md border bg-background px-2 py-2"
value={policy}
onChange={(event) =>
setPolicy(event.target.value as EventCreatePolicy)
@@ -72,7 +74,7 @@ export function DeploymentSettingsForm({
<option value="open">Open (quota still applies)</option>
<option value="invite">Invite code required</option>
<option value="admin_only">Administrators only</option>
</select>
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="limit">Default event limit for new groups</FieldLabel>
@@ -0,0 +1,36 @@
import { recordEmailWebhook, verifyEmailWebhook } from "@album/email/webhooks";
export const runtime = "nodejs";
export async function POST(request: Request) {
const secret = process.env.RESEND_WEBHOOK_SECRET;
if (!secret) return new Response("Webhook not configured", { status: 503 });
// Read a bounded raw body: signature verification must precede parsing/storage.
const reader = request.body?.getReader();
if (!reader) return new Response("Missing body", { status: 400 });
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > 256 * 1024) {
await reader.cancel();
return new Response("Payload too large", { status: 413 });
}
chunks.push(value);
}
let event;
try {
event = verifyEmailWebhook(Buffer.concat(chunks).toString("utf8"), request.headers, secret);
} catch {
return new Response("Invalid signature or event", { status: 400 });
}
try {
if (event) await recordEmailWebhook(event);
return new Response("OK");
} catch {
// Non-2xx requests are retried by Resend. Never log callback payloads.
return new Response("Temporarily unavailable", { status: 503 });
}
}
@@ -0,0 +1,23 @@
"use client";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
export function EmailHistory({ eventId }: { eventId: string }) {
const history = api.manager.emailHistory.useQuery({ eventId }, { refetchInterval: 5000 });
const retry = api.manager.retryEmail.useMutation({ onSuccess: () => { toast.success("Retry queued with the original idempotency key"); void history.refetch(); }, onError: (error) => toast.error(error.message) });
return <section className="flex flex-col gap-3" aria-label="Email delivery history">
<h3 className="text-lg font-semibold">Email delivery</h3>
<p className="text-xs text-muted-foreground">Sent means accepted by the provider. Delivered means accepted by the recipients mail server, not guaranteed inbox placement. Mailpit does not report delivery outcomes.</p>
{history.isError ? <p role="alert">Could not load deliveries.</p> : history.isLoading ? <p>Loading deliveries</p> : !history.data?.length ? <p className="text-muted-foreground">No emails queued yet.</p> : null}
{history.data?.map((row) => <div key={row.id} className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-2"><span>{row.recipient}</span><Badge variant="secondary">{row.status.charAt(0).toUpperCase() + row.status.slice(1)}</Badge></div>
<p className="text-xs text-muted-foreground">{row.provider} · {row.attempts} attempts · {new Date(row.updatedAt).toLocaleString()}</p>
{row.outcome ? <div><Badge variant={["bounced", "complained", "failed"].includes(row.outcome) ? "destructive" : "outline"}>{row.outcome === "delivery_delayed" ? "Delivery delayed" : row.outcome.charAt(0).toUpperCase() + row.outcome.slice(1)}</Badge></div> : null}
{row.providerId ? <p className="break-all font-mono text-xs">Provider reference: {row.providerId}</p> : null}
{row.lastError ? <p className="text-sm">{row.lastError}</p> : null}
{row.status === "review" && row.provider === "resend" && row.firstAttemptAt && Date.now() - row.firstAttemptAt.getTime() < 23 * 3600000 ? <Button variant="outline" className="self-start" disabled={retry.isPending} onClick={() => retry.mutate({ eventId, deliveryId: row.id })}>Retry safely</Button> : null}
</div>)}
</section>;
}
@@ -1,6 +1,11 @@
"use client";
import { api } from "@/trpc/react";
import { useState } from "react";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ImageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
Card,
CardContent,
@@ -10,7 +15,9 @@ import {
} from "@/components/ui/card";
export function EventAudit({ eventId }: { eventId: string }) {
const audit = api.manager.audit.useQuery({ eventId });
const [page, setPage] = useState(0);
const [category, setCategory] = useState<"all" | "event" | "photo" | "note" | "guest" | "gallery" | "submission">("all");
const audit = api.manager.audit.useQuery({ eventId, page, category });
return (
<Card>
<CardHeader>
@@ -18,15 +25,44 @@ export function EventAudit({ eventId }: { eventId: string }) {
<CardDescription>Recent changes for this event.</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Select value={category} onValueChange={(value) => { setCategory(value as typeof category); setPage(0); }}><SelectTrigger aria-label="Activity category"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>
{["all", "event", "photo", "note", "guest", "gallery", "submission"].map((value) => <SelectItem key={value} value={value}>{value.charAt(0).toUpperCase() + value.slice(1)}</SelectItem>)}
</SelectGroup></SelectContent></Select>
<Button variant="outline" disabled={!page || audit.isFetching} onClick={() => setPage(page - 1)}>Newer</Button>
<Button variant="outline" disabled={audit.isFetching || audit.data?.length !== 50} onClick={() => setPage(page + 1)}>Older</Button>
</div>
{audit.isLoading ? <p role="status">Loading activity</p> : null}
{audit.isError ? <p role="alert">Could not load activity.</p> : null}
{audit.data?.length === 0 ? <p className="text-sm text-muted-foreground">No activity recorded yet.</p> : null}
<ul className="flex flex-col gap-2 text-sm">
{(audit.data ?? []).map((row) => (
<li key={row.id} className="flex justify-between gap-3">
<span>
{row.action} · {row.subjectType}
</span>
<span className="text-muted-foreground">
{new Date(row.createdAt).toLocaleString()}
</span>
<li key={row.id} className="flex flex-col gap-2 rounded-lg border p-4">
<div className="flex flex-wrap justify-between gap-2">
<p className="font-medium">{row.actorName ?? String(row.metadata.actorName ?? (row.actorUserId ? "Deleted account" : "System"))} · {row.action.replaceAll(".", " ")}</p>
<time dateTime={new Date(row.createdAt).toISOString()} className="text-muted-foreground">{new Date(row.createdAt).toLocaleString()}</time>
</div>
<p>{row.subjectLabel}</p>
<p className="break-all font-mono text-xs text-muted-foreground">{row.subjectType}: {row.subjectId}</p>
{row.assetUrl ? (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" className="self-start">
<ImageIcon data-icon="inline-start" aria-hidden="true" />View referenced photo
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Referenced photo</DialogTitle>
<DialogDescription className="break-all">{row.subjectLabel}</DialogDescription>
</DialogHeader>
<img src={row.assetUrl} alt="Photo referenced by this audit entry" className="max-h-[70svh] w-full rounded-lg object-contain" />
</DialogContent>
</Dialog>
) : null}
{Object.keys(row.metadata).length ? <dl className="grid gap-1 text-xs">
{Object.entries(row.metadata).filter(([key]) => key !== "actorName" && key !== "subjectLabel").map(([key, value]) => <div key={key} className="flex flex-wrap gap-2"><dt className="font-medium">{key}</dt><dd className="break-all">{String(value ?? "—")}</dd></div>)}
</dl> : null}
</li>
))}
</ul>
@@ -1,6 +1,9 @@
"use client";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { CheckIcon, EyeOffIcon } from "lucide-react";
import { toast } from "sonner";
import {
Card,
CardContent,
@@ -15,7 +18,12 @@ import {
EmptyTitle,
} from "@/components/ui/empty";
export function EventNotes({ eventId }: { eventId: string }) {
export function EventNotes({ eventId, canManage }: { eventId: string; canManage: boolean }) {
const utils = api.useUtils();
const moderate = api.manager.moderateNote.useMutation({
onSuccess: () => { void utils.manager.notes.invalidate({ eventId }); },
onError: (error) => toast.error(error.message),
});
const notes = api.manager.notes.useQuery({ eventId });
const withNotes = (notes.data ?? []).filter((guest) => guest.note);
@@ -24,7 +32,7 @@ export function EventNotes({ eventId }: { eventId: string }) {
<CardHeader>
<CardTitle>Notes</CardTitle>
<CardDescription>
Messages guests left for the event people.
Messages guests left for the event people. Approved notes appear publicly only when note publishing is enabled in Settings.
</CardDescription>
</CardHeader>
<CardContent>
@@ -33,7 +41,7 @@ export function EventNotes({ eventId }: { eventId: string }) {
<EmptyHeader>
<EmptyTitle>No notes yet</EmptyTitle>
<EmptyDescription>
Guests can leave a note when they upload.
Guests can send notes with or without photos.
</EmptyDescription>
</EmptyHeader>
</Empty>
@@ -45,6 +53,11 @@ export function EventNotes({ eventId }: { eventId: string }) {
{guest.displayName ?? "Anonymous"}
</p>
<p className="mt-2 whitespace-pre-wrap text-sm">{guest.note}</p>
{canManage ? <Button type="button" variant="outline" className="mt-3" disabled={moderate.isPending}
onClick={() => moderate.mutate({ eventId, guestId: guest.id, approved: !guest.noteApproved })}>
{guest.noteApproved ? <EyeOffIcon data-icon="inline-start" /> : <CheckIcon data-icon="inline-start" />}
{guest.noteApproved ? "Make private" : "Approve for publishing"}
</Button> : null}
</li>
))}
</ul>
@@ -1,5 +1,7 @@
"use client";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useState } from "react";
import { toast } from "sonner";
import type { EventRole } from "@album/contracts";
@@ -7,6 +9,10 @@ import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { MailIcon } from "lucide-react";
import { effectiveEvent } from "@/lib/event-lifecycle";
import { galleryIsPublic } from "@/lib/publishing";
import { EmailHistory } from "./email-history";
import {
Card,
CardContent,
@@ -16,6 +22,7 @@ import {
} from "@/components/ui/card";
const roles: EventRole[] = ["owner", "manager", "moderator", "viewer"];
const roleLabels: Record<EventRole, string> = { owner: "Owner", manager: "Manager", moderator: "Moderator", viewer: "Viewer" };
export function EventPeople({
eventId,
@@ -28,6 +35,16 @@ export function EventPeople({
}) {
const utils = api.useUtils();
const members = api.manager.members.useQuery({ eventId });
const guests = api.manager.guests.useQuery({ eventId }, { refetchInterval: 5000 });
const [guestSearch, setGuestSearch] = useState("");
const [preview, setPreview] = useState(false);
const emailPreview = api.manager.emailPreview.useQuery({ eventId }, { enabled: preview });
const notify = api.manager.notifyGuests.useMutation({
onSuccess: async (result) => { toast.success(`${result.queued} emails queued`); setPreview(false); await utils.manager.guests.invalidate({ eventId }); },
onError: (error) => toast.error(error.message),
});
const previouslyContacted = new Set((guests.data ?? []).filter((guest) => guest.email && (guest.notifiedAt || guest.notificationClaimedAt)).map((guest) => guest.email!.toLowerCase()));
const eligible = new Set((guests.data ?? []).filter((guest) => guest.email && guest.notifyWhenReady && !previouslyContacted.has(guest.email.toLowerCase())).map((guest) => guest.email!.toLowerCase())).size;
const [email, setEmail] = useState("");
const [role, setRole] = useState<EventRole>("manager");
const setMember = api.manager.setMember.useMutation({
@@ -55,10 +72,33 @@ export function EventPeople({
<CardHeader>
<CardTitle>People</CardTitle>
<CardDescription>
The couple can both be owners. Managers run the day. Moderators review photos.
Guests attend or receive gallery notifications. Accounts have permission to manage this event; attending does not grant account access.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<h3 className="text-lg font-semibold">Guests</h3>
<Input aria-label="Search guests" placeholder="Search guests by name or email" value={guestSearch} onChange={(e) => setGuestSearch(e.target.value)} />
{guests.isLoading ? <p>Loading guests</p> : guests.isError ? <p role="alert">Could not load guests.</p> : !guests.data?.length ? <p className="text-muted-foreground">No guests yet. Guests appear when they register their details or contribute.</p> : null}
<ul className="flex flex-col gap-3">
{(guests.data ?? []).filter((guest) => `${guest.displayName ?? ""} ${guest.email ?? ""}`.toLowerCase().includes(guestSearch.toLowerCase())).map((guest) => <li key={guest.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3">
<div><p className="font-medium">{guest.displayName ?? "Anonymous guest"}</p><p className="text-xs text-muted-foreground">{guest.email ?? "No email provided"}</p></div>
<Badge variant="secondary">{guest.notifiedAt ? `Emailed ${new Date(guest.notifiedAt).toLocaleDateString()}` : guest.notificationClaimedAt ? "Delivery pending / needs review" : guest.email && guest.notifyWhenReady ? "Opted into gallery email" : "Not subscribed"}</Badge>
</li>)}
</ul>
{event.data?.permissions.includes("gallery.release") ? <div className="flex flex-col gap-3">
<Button type="button" variant="outline" onClick={() => setPreview(!preview)}>
<MailIcon data-icon="inline-start" aria-hidden="true" />Preview completion email ({eligible})
</Button>
<p className="text-xs text-muted-foreground">Complete the event and make the gallery public first. Only opted-in guests who have not already been emailed are eligible; repeated addresses are deduplicated.</p>
{preview ? <div className="flex flex-col gap-3 rounded-lg border p-4">
<p className="font-medium">The gallery for {event.data.title} is ready</p>
{emailPreview.data ? <iframe title="Gallery email preview" sandbox="" srcDoc={emailPreview.data.html} className="h-[36rem] w-full rounded-lg border" /> : <p>{emailPreview.isError ? "Could not load the preview." : "Loading preview…"}</p>}
<p className="text-sm">Send to {eligible} opted-in email addresses.</p>
<Button type="button" disabled={notify.isPending || !eligible || effectiveEvent(event.data).status !== "closed" || !galleryIsPublic(event.data.galleryPolicy, event.data.galleryReleasedAt, event.data.galleryVisibleAt)} onClick={() => notify.mutate({ eventId })}><MailIcon data-icon="inline-start" />{notify.isPending ? "Queueing…" : "Queue completion emails"}</Button>
</div> : null}
</div> : null}
{event.data?.permissions.includes("gallery.release") ? <EmailHistory eventId={eventId} /> : null}
<h3 className="text-lg font-semibold">Accounts with access</h3>
<ul className="flex flex-col gap-2">
{(members.data ?? []).map((member) => (
<li key={member.id} className="flex items-center justify-between gap-3">
@@ -67,7 +107,7 @@ export function EventPeople({
<p className="text-xs text-muted-foreground">{member.email}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{member.role}</Badge>
<Badge variant="secondary">{roleLabels[member.role]}</Badge>
{canManage ? (
<Button
size="sm"
@@ -103,19 +143,22 @@ export function EventPeople({
onChange={(event) => setEmail(event.target.value)}
className="max-w-xs"
/>
<select
className="rounded-md border bg-background px-2 py-1.5 text-sm"
value={role}
onChange={(event) => setRole(event.target.value as EventRole)}
>
<Select value={role} onValueChange={(value) => setRole(value as EventRole)}>
<SelectTrigger aria-label="Account role" className="min-w-36">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="start">
<SelectGroup>
{roles
.filter((value) => value !== "owner" || canGrantOwner)
.map((value) => (
<option key={value} value={value}>
{value}
</option>
<SelectItem key={value} value={value}>
{roleLabels[value]}
</SelectItem>
))}
</select>
</SelectGroup>
</SelectContent>
</Select>
<Button type="submit" disabled={setMember.isPending}>
Add existing user
</Button>
@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
const dateFields = [
["startsAt", "Event starts"], ["endsAt", "Event ends"],
["publishAt", "Event becomes public"], ["submissionsOpenAt", "Submissions open"],
["submissionsCloseAt", "Submissions close"], ["galleryVisibleAt", "Gallery becomes visible"], ["notesVisibleAt", "Notes become visible"],
] as const;
type DateKey = typeof dateFields[number][0];
function localDate(value: Date | null) {
return value ? new Date(value.getTime() - value.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : "";
}
export function EventSchedule({ eventId }: { eventId: string }) {
const event = api.manager.event.useQuery({ eventId });
const [dates, setDates] = useState<Partial<Record<DateKey, string>>>({});
const router = useRouter();
const utils = api.useUtils();
async function refresh() { await utils.manager.event.invalidate({ eventId }); router.refresh(); }
const update = api.manager.updateEvent.useMutation({ onSuccess: async () => { setDates({}); toast.success("Schedule saved"); await refresh(); }, onError: (error) => toast.error(error.message) });
const complete = api.manager.completeEvent.useMutation({ onSuccess: async () => { toast.success("Event completed. You can notify guests from People."); await refresh(); }, onError: (error) => toast.error(error.message) });
return <Card>
<CardHeader><CardTitle>Schedule & completion</CardTitle><CardDescription>Times use your devices timezone and are stored with timezone information. Empty dates mean manual control. Visibility schedules respect never publicly and approval settings.</CardDescription></CardHeader>
<CardContent>
{event.isError ? <p role="alert">Could not load the schedule.</p> : !event.data ? <p>Loading schedule</p> : <form className="flex flex-col gap-4" onSubmit={(e) => {
e.preventDefault();
update.mutate({ eventId, ...Object.fromEntries(Object.entries(dates).map(([key, value]) => [key, value ? new Date(value) : null])) });
}}>
<FieldGroup className="grid gap-4 sm:grid-cols-2">
{dateFields.map(([key, label]) => <Field key={key}><FieldLabel htmlFor={`schedule-${key}`}>{label}</FieldLabel>
<Input id={`schedule-${key}`} type="datetime-local" value={dates[key] ?? localDate(event.data![key])} onChange={(e) => setDates({ ...dates, [key]: e.target.value })} />
</Field>)}
</FieldGroup>
<p className="text-sm text-muted-foreground">Setting a future public date hides the guest page until then. Gallery and note schedules never approve existing private content. Submission closing ends new contributions; it does not hide the gallery. Dates alone do not send email.</p>
<div className="flex flex-wrap gap-2"><Button type="submit" disabled={update.isPending || !Object.keys(dates).length}>Save schedule</Button>
<Button type="button" variant="outline" disabled={complete.isPending || Boolean(event.data.completedAt)} onClick={() => complete.mutate({ eventId })}>{event.data.completedAt ? "Event completed" : "Complete event & close submissions"}</Button></div>
</form>}
</CardContent>
</Card>;
}
@@ -1,6 +1,11 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { CheckIcon, ImageIcon, WandSparklesIcon } from "lucide-react";
import { eventSlugSchema } from "@album/contracts";
import { LocationInput } from "@/components/location-input";
import { BannerUpload } from "@/components/banner-upload";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
@@ -9,6 +14,8 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { NativeSelect } from "@/components/ui/native-select";
import type { PublishingPolicy } from "@album/contracts";
import {
Card,
CardContent,
@@ -22,38 +29,103 @@ export function EventSettingsForm({
title,
slug,
description,
location,
latitude,
longitude,
bannerPhotoId,
customBannerId,
status,
listed,
uploadEnabled,
galleryReleased,
notesPolicy,
galleryPolicy,
showPhotoStats,
showSubmitterStats,
showNoteStats,
}: {
eventId: string;
title: string;
slug: string;
description: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
bannerPhotoId: string | null;
customBannerId: string | null;
status: "draft" | "published" | "closed";
listed: boolean;
uploadEnabled: boolean;
galleryReleased: boolean;
notesPolicy: PublishingPolicy;
galleryPolicy: PublishingPolicy;
showPhotoStats: boolean;
showSubmitterStats: boolean;
showNoteStats: boolean;
}) {
const [publishing, setPublishing] = useState({ notesPolicy, galleryPolicy, showPhotoStats, showSubmitterStats, showNoteStats });
const [formTitle, setFormTitle] = useState(title);
const [formSlug, setFormSlug] = useState(slug);
const [checkedSlug, setCheckedSlug] = useState(slug);
const [generatingSlug, setGeneratingSlug] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setCheckedSlug(formSlug), 350);
return () => clearTimeout(timer);
}, [formSlug]);
const slugValid = eventSlugSchema.safeParse(formSlug).success;
const availability = api.manager.checkSlug.useQuery({ eventId, slug: checkedSlug }, {
enabled: eventSlugSchema.safeParse(checkedSlug).success && checkedSlug !== slug,
staleTime: 0,
retry: false,
});
const slugChecking = formSlug !== slug && (formSlug !== checkedSlug || availability.isFetching || availability.isPending);
const slugAvailable = formSlug === slug || (formSlug === checkedSlug && !availability.isError && availability.data?.available === true);
const canSaveSlug = slugValid && slugAvailable && !slugChecking && !generatingSlug;
const slugMessage = !slugValid
? "Use 364 lowercase letters or numbers, with single hyphens between words."
: generatingSlug ? "Generating an available link…"
: formSlug === slug ? "This is your current guest link."
: availability.isError && formSlug === checkedSlug ? "Could not check availability. Try again."
: slugChecking ? "Checking availability…"
: slugAvailable ? "Available — save to use this guest link."
: "This guest link is already taken.";
const [formDescription, setFormDescription] = useState(description ?? "");
const [formLocation, setFormLocation] = useState(location ?? "");
const [coordinates, setCoordinates] = useState(
latitude !== null && longitude !== null ? { latitude, longitude } : null,
);
const [formBanner, setFormBanner] = useState<string | null>(bannerPhotoId);
const [formCustomBanner, setFormCustomBanner] = useState<string | null>(customBannerId);
const router = useRouter();
const photos = api.manager.photos.useQuery({ eventId });
const bannerPhotos = (photos.data ?? []).filter((photo) =>
photo.visibility === "public" && photo.processingStatus === "ready" && photo.displayUrl,
);
const [formUploadEnabled, setFormUploadEnabled] = useState(uploadEnabled);
const [formListed, setFormListed] = useState(listed);
const utils = api.useUtils();
async function generateSlug() {
setGeneratingSlug(true);
try {
const result = await utils.manager.generateSlug.fetch({ eventId, title: formTitle });
setFormSlug(result.slug);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not generate a link");
} finally { setGeneratingSlug(false); }
}
const updateEvent = api.manager.updateEvent.useMutation({
onSuccess: async () => {
toast.success("Event saved");
await utils.manager.event.invalidate({ eventId });
router.refresh();
},
onError: (error) => toast.error(error.message),
});
const release = api.manager.releaseGallery.useMutation({
onSuccess: async (result) => {
toast.success(
result.notified
? `Gallery released. ${result.notified} guests emailed.`
result.queued
? `Gallery released. ${result.queued} emails queued.`
: "Gallery released",
);
await utils.manager.event.invalidate({ eventId });
@@ -66,11 +138,17 @@ export function EventSettingsForm({
uploadEnabled?: boolean;
listed?: boolean;
}) {
if (!canSaveSlug) { toast.error("Choose a valid, available guest link before saving."); return; }
updateEvent.mutate({
...publishing,
eventId,
title: formTitle,
slug: formSlug,
description: formDescription.trim() || null,
location: formLocation.trim() || null,
locationCoordinates: coordinates,
bannerPhotoId: formBanner,
customBannerId: formCustomBanner,
uploadEnabled: next?.uploadEnabled ?? formUploadEnabled,
listed: next?.listed ?? formListed,
status: next?.status,
@@ -83,7 +161,7 @@ export function EventSettingsForm({
<CardTitle className="text-lg font-semibold tracking-tight">Event settings</CardTitle>
<CardDescription>
The guest link works once published. Listing puts it on the homepage.
Release the gallery when you are ready for the public to see photos.
Choose how photos and notes are published, and which counts guests can see.
</CardDescription>
</CardHeader>
<CardContent>
@@ -95,6 +173,35 @@ export function EventSettingsForm({
}}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="notes-policy">Publish notes</FieldLabel>
<NativeSelect id="notes-policy" value={publishing.notesPolicy} onChange={(e) => setPublishing({ ...publishing, notesPolicy: e.target.value as PublishingPolicy })}>
<option value="never">Never publicly (organizers only)</option>
<option value="approved">After organizer approval</option>
<option value="automatic">Automatically</option>
</NativeSelect>
<FieldDescription>Automatic publishing applies to new or edited notes. Existing private notes stay private until approved.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="gallery-policy">Publish gallery</FieldLabel>
<NativeSelect id="gallery-policy" value={publishing.galleryPolicy} onChange={(e) => setPublishing({ ...publishing, galleryPolicy: e.target.value as PublishingPolicy })}>
<option value="never">Never publicly (collect photos only)</option>
<option value="approved">Approved photos, after gallery release</option>
<option value="automatic">Automatically publish new photos</option>
</NativeSelect>
<FieldDescription>Automatic mode opens the gallery and publishes new uploads after processing. Existing pending or hidden photos stay private until approved.</FieldDescription>
</Field>
{([
["showPhotoStats", "Show images uploaded"],
["showSubmitterStats", "Show submitters"],
["showNoteStats", "Show notes sent"],
] as const).map(([key, label]) => (
<Field key={key} orientation="horizontal">
<FieldLabel htmlFor={key}>{label}</FieldLabel>
<Switch id={key} checked={publishing[key]} onCheckedChange={(checked) => setPublishing({ ...publishing, [key]: checked })} />
</Field>
))}
<FieldDescription>Public counts include private submissions, but never disclose their contents or identities.</FieldDescription>
<Field>
<FieldLabel htmlFor="title">Title</FieldLabel>
<Input
@@ -104,24 +211,78 @@ export function EventSettingsForm({
required
/>
</Field>
<Field>
<Field data-invalid={!slugValid || (formSlug !== slug && !slugChecking && !slugAvailable && !availability.isError)}>
<FieldLabel htmlFor="slug">Guest link slug</FieldLabel>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
id="slug"
value={formSlug}
onChange={(event) => setFormSlug(event.target.value)}
disabled={generatingSlug}
minLength={3}
maxLength={64}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
aria-invalid={!slugValid || (formSlug !== slug && !slugChecking && !slugAvailable && !availability.isError)}
aria-describedby="slug-feedback slug-preview"
required
/>
<FieldDescription>Guests visit /e/{formSlug || "slug"}</FieldDescription>
<Button type="button" variant="outline" disabled={generatingSlug || !formTitle.trim() || formTitle.trim().length > 120} onClick={() => void generateSlug()}>
{generatingSlug ? <Spinner data-icon="inline-start" /> : <WandSparklesIcon data-icon="inline-start" aria-hidden="true" />}Generate
</Button>
</div>
<FieldDescription id="slug-preview" className="break-all">Guests visit /e/{formSlug || "your-event"}</FieldDescription>
<p id="slug-feedback" role="status" aria-live="polite" className="text-sm text-muted-foreground">{slugMessage}</p>
{availability.isError && formSlug === checkedSlug && formSlug !== slug ? <Button type="button" variant="outline" onClick={() => void availability.refetch()}>Retry check</Button> : null}
{formSlug !== slug ? <FieldDescription>Changing this will stop the old guest link from working. The link is not reserved until you save.</FieldDescription> : null}
</Field>
<Field>
<FieldLabel htmlFor="description">Description</FieldLabel>
<Textarea
id="description"
maxLength={2000}
placeholder="Tell guests what the event is about and what to expect."
value={formDescription}
onChange={(event) => setFormDescription(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="location">Location</FieldLabel>
<LocationInput eventId={eventId} value={formLocation}
latitude={coordinates?.latitude ?? null} longitude={coordinates?.longitude ?? null}
onChange={(value, match) => {
setFormLocation(value);
setCoordinates(match ? { latitude: match.latitude, longitude: match.longitude } : null);
}} />
<FieldDescription>Shown on the event's guest page.</FieldDescription>
</Field>
<Field>
<span id="banner-label" className="text-sm font-medium">Event Banner</span>
<FieldDescription>
Upload a dedicated banner, or choose an approved gallery photo. Dedicated banners appear as soon as the event is published; gallery photos follow gallery publishing settings.
</FieldDescription>
<BannerUpload eventId={eventId} selectedId={formCustomBanner}
onSelect={(id) => { setFormCustomBanner(id); setFormBanner(null); }} />
<div role="group" aria-labelledby="banner-label" className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<button type="button" aria-pressed={formBanner === null && formCustomBanner === null} onClick={() => { setFormBanner(null); setFormCustomBanner(null); }}
className="flex aspect-[3/2] flex-col items-center justify-center gap-2 rounded-lg border-2 border-border bg-muted text-sm text-muted-foreground aria-pressed:border-primary aria-pressed:text-primary focus-visible:outline-2 focus-visible:outline-ring">
<ImageIcon aria-hidden="true" className="size-5" />No Banner
</button>
{bannerPhotos.map((photo, index) => (
<button key={photo.id} type="button" aria-label={`Use photo ${index + 1} as banner`}
aria-pressed={formBanner === photo.id} onClick={() => { setFormBanner(photo.id); setFormCustomBanner(null); }}
className="relative aspect-[3/2] overflow-hidden rounded-lg border-2 border-transparent aria-pressed:border-primary focus-visible:outline-2 focus-visible:outline-ring">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt="" width={300} height={200} loading="lazy" className="size-full object-cover" />
{formBanner === photo.id ? <span className="absolute right-2 top-2 rounded-full bg-primary p-1 text-primary-foreground"><CheckIcon aria-hidden="true" className="size-4" /></span> : null}
</button>
))}
</div>
{photos.isLoading ? <p className="text-xs text-muted-foreground">Loading photos</p> : null}
{photos.isError ? <p role="alert" className="text-sm text-destructive">Could not load banner photos. Try reopening Settings.</p> : null}
{!photos.isLoading && !photos.isError && bannerPhotos.length === 0 ? <p className="text-sm text-muted-foreground">Upload and approve an event photo to use it as a banner.</p> : null}
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor="uploads">Accept uploads</FieldLabel>
<Switch
@@ -146,7 +307,7 @@ export function EventSettingsForm({
</Field>
</FieldGroup>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={updateEvent.isPending}>
<Button type="submit" disabled={updateEvent.isPending || !canSaveSlug}>
{updateEvent.isPending ? <Spinner data-icon="inline-start" /> : null}
Save
</Button>
@@ -182,12 +343,12 @@ export function EventSettingsForm({
<Button
type="button"
variant="secondary"
disabled={release.isPending}
disabled={release.isPending || galleryPolicy === "never"}
onClick={() =>
release.mutate({ eventId, notifyGuests: true })
}
>
{galleryReleased ? "Notify guests again" : "Release gallery"}
{galleryReleased ? "Notify remaining guests" : "Release gallery"}
</Button>
</div>
</form>
@@ -8,6 +8,8 @@ import {
DownloadIcon,
EyeOffIcon,
LockIcon,
MoreHorizontalIcon,
ExpandIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
@@ -30,6 +32,16 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup,
DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
const visibilityLabels = {
pending: "Needs Review", public: "Public", hidden: "Hidden",
private: "Private", rejected: "Rejected",
} as const;
const processingOrder = {
pending: 0,
processing: 1,
@@ -72,13 +84,14 @@ export function ModerationGrid({
},
onError: (error) => toast.error(error.message),
});
const [previewId, setPreviewId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
if (photos.isLoading) {
return (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="aspect-square w-full rounded-xl" />
<Skeleton key={index} className="aspect-[3/2] w-full rounded-xl" />
))}
</div>
);
@@ -92,6 +105,15 @@ export function ModerationGrid({
);
});
const preview = rows.find((photo) => photo.id === previewId);
const busy = moderate.isPending || moderateSubmission.isPending || remove.isPending;
if (photos.isError) {
return <Empty className="border"><EmptyHeader><EmptyTitle>Photos couldn't load</EmptyTitle>
<EmptyDescription>{photos.error.message}</EmptyDescription></EmptyHeader>
<Button variant="outline" onClick={() => photos.refetch()}>Try Again</Button></Empty>;
}
if (rows.length === 0) {
return (
<Empty className="border">
@@ -107,125 +129,125 @@ export function ModerationGrid({
return (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((photo) => (
<article
key={photo.id}
className="flex flex-col overflow-hidden rounded-xl bg-card ring-1 ring-foreground/10"
>
<div className="aspect-square bg-muted">
<button
type="button"
onClick={() => setPreviewId(photo.id)}
disabled={!photo.thumbUrl && !photo.displayUrl}
aria-label={`Preview photo from ${photo.contributorName ?? "Anonymous"}`}
className="group relative aspect-[3/2] w-full overflow-hidden bg-muted text-left focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
>
{photo.thumbUrl || photo.displayUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photo.thumbUrl ?? photo.displayUrl ?? ""}
alt=""
className="size-full object-cover"
/>
<img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt={`Photo from ${photo.contributorName ?? "Anonymous"}`}
width={600} height={400} loading="lazy" className="size-full object-cover" />
) : (
<div className="flex size-full items-center justify-center text-xs text-muted-foreground">
{photo.processingStatus}
</div>
)}
</div>
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-2">
<Badge variant="secondary">{photo.visibility}</Badge>
<span className="truncate text-xs text-muted-foreground">
{photo.contributorName ?? "Anonymous"}
<span className="flex size-full items-center justify-center text-sm text-muted-foreground">
{photo.processingStatus === "failed" ? "Processing Failed" : "Preparing Photo…"}
</span>
)}
<Badge variant="secondary" className="absolute left-3 top-3 bg-card/95 text-card-foreground shadow-sm">
{photo.visibility === "public" ? <CheckIcon aria-hidden="true" /> : null}
{visibilityLabels[photo.visibility]}
</Badge>
{photo.thumbUrl || photo.displayUrl ? (
<span className="absolute bottom-3 right-3 rounded-md bg-card/95 p-2 text-card-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<ExpandIcon className="size-4" aria-hidden="true" />
</span>
) : null}
</button>
<div className="flex min-h-16 items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{photo.contributorName ?? "Anonymous"}</p>
<p className="text-xs text-muted-foreground">
{photo.processingStatus !== "ready" ? photo.processingStatus : photo.visibility === "public" ? "Visible in the guest gallery" : "Not in the guest gallery"}
</p>
</div>
<div className="flex flex-wrap gap-1">
{canModerate && photo.processingStatus === "ready" ? (
<>
<Button
size="sm"
disabled={moderate.isPending}
onClick={() =>
moderate.mutate({ photoId: photo.id, visibility: "public" })
}
>
<CheckIcon data-icon="inline-start" />
Public
{canModerate && photo.processingStatus === "ready" && photo.visibility === "pending" ? (
<Button className="min-h-11" disabled={busy}
onClick={() => moderate.mutate({ photoId: photo.id, visibility: "public" })}>
<CheckIcon data-icon="inline-start" aria-hidden="true" />Approve
</Button>
) : null}
{canModerate || canDelete || photo.originalUrl ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="size-11 shrink-0"
aria-label={`Photo actions for ${photo.contributorName ?? "Anonymous"}`} disabled={busy}>
<MoreHorizontalIcon aria-hidden="true" />
</Button>
<Button
size="sm"
variant="outline"
disabled={moderate.isPending}
onClick={() =>
moderate.mutate({ photoId: photo.id, visibility: "hidden" })
}
>
<EyeOffIcon data-icon="inline-start" />
Hide
</Button>
{canPrivate ? (
<Button
size="sm"
variant="outline"
disabled={moderate.isPending}
onClick={() =>
moderate.mutate({
photoId: photo.id,
visibility: "private",
})
}
>
<LockIcon data-icon="inline-start" />
Keep
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
{canModerate && photo.processingStatus === "ready" ? (
<>
<DropdownMenuLabel>Photo Visibility</DropdownMenuLabel>
<DropdownMenuGroup>
{([
{ value: "public", label: "Make Public", icon: CheckIcon },
{ value: "hidden", label: "Hide from Gallery", icon: EyeOffIcon },
...(canPrivate ? [{ value: "private" as const, label: "Keep Private", icon: LockIcon }] : []),
{ value: "rejected", label: "Reject Photo", icon: XIcon },
] as const).map(({ value, label, icon: Icon }) => (
<DropdownMenuItem key={value} className="min-h-11 px-3"
disabled={photo.visibility === value}
onSelect={() => moderate.mutate({ photoId: photo.id, visibility: value })}>
<Icon aria-hidden="true" />{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
) : null}
<Button
size="sm"
variant="outline"
disabled={moderate.isPending}
onClick={() =>
moderate.mutate({
photoId: photo.id,
visibility: "rejected",
})
}
>
<XIcon data-icon="inline-start" />
Reject
</Button>
<Button
size="sm"
variant="ghost"
disabled={moderateSubmission.isPending}
onClick={() =>
moderateSubmission.mutate({
submissionId: photo.submissionId,
visibility: "hidden",
})
}
>
Hide batch
</Button>
</>
) : null}
{photo.originalUrl ? (
<Button size="sm" variant="ghost" asChild>
<a href={photo.originalUrl} target="_blank" rel="noreferrer">
<DownloadIcon data-icon="inline-start" />
Original
</a>
</Button>
) : null}
{canDelete ? (
<Button
size="sm"
variant="destructive"
onClick={() => setPendingDelete(photo.id)}
>
<Trash2Icon data-icon="inline-start" />
Delete
</Button>
) : null}
</div>
<DropdownMenuGroup>
{photo.originalUrl ? (
<DropdownMenuItem asChild className="min-h-11 px-3">
<a href={photo.originalUrl} target="_blank" rel="noreferrer">
<DownloadIcon aria-hidden="true" />Open Original
</a>
</DropdownMenuItem>
) : null}
{canModerate && photo.processingStatus === "ready" ? (
<DropdownMenuItem className="min-h-11 px-3"
onSelect={() => moderateSubmission.mutate({ submissionId: photo.submissionId, visibility: "hidden" })}>
<EyeOffIcon aria-hidden="true" />Hide Entire Submission
</DropdownMenuItem>
) : null}
</DropdownMenuGroup>
{canDelete ? (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" className="min-h-11 px-3" onSelect={() => setPendingDelete(photo.id)}>
<Trash2Icon aria-hidden="true" />Delete Photo
</DropdownMenuItem>
</DropdownMenuGroup>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</article>
))}
</div>
<Dialog open={Boolean(preview)} onOpenChange={(open) => { if (!open) setPreviewId(null); }}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader className="pr-8">
<DialogTitle>Photo Preview</DialogTitle>
<DialogDescription>{preview?.contributorName ?? "Anonymous"} · {preview ? visibilityLabels[preview.visibility] : ""}</DialogDescription>
</DialogHeader>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview.displayUrl ?? preview.thumbUrl ?? ""} alt={`Photo from ${preview.contributorName ?? "Anonymous"}`}
width={2048} height={1365} className="max-h-[70dvh] w-full rounded-lg object-contain" />
) : null}
</DialogContent>
</Dialog>
<Dialog
open={Boolean(pendingDelete)}
onOpenChange={(open) => {
@@ -6,7 +6,11 @@ import { EventSettingsForm } from "./event-settings-form";
import { CopyGuestLink, ModerationGrid } from "./moderation-grid";
import { EventPeople } from "./event-people";
import { EventNotes } from "./event-notes";
import { galleryIsPublic } from "@/lib/publishing";
import { EventAudit } from "./event-audit";
import { EventSchedule } from "./event-schedule";
import { eventStatusLabel } from "@/lib/event-status";
import { effectiveEvent } from "@/lib/event-lifecycle";
export default async function EventDashboardPage({
params,
@@ -47,16 +51,29 @@ export default async function EventDashboardPage({
id: "settings",
label: "Settings",
content: (
<div className="flex flex-col gap-4">
<EventSchedule eventId={event.id} />
<EventSettingsForm
eventId={event.id}
title={event.title}
slug={event.slug}
description={event.description}
status={event.status}
location={event.location}
latitude={event.latitude}
longitude={event.longitude}
bannerPhotoId={event.bannerPhotoId}
customBannerId={event.customBannerId}
status={effectiveEvent(event).status}
listed={event.listed}
uploadEnabled={event.uploadEnabled}
galleryReleased={Boolean(event.galleryReleasedAt)}
notesPolicy={event.notesPolicy}
galleryPolicy={event.galleryPolicy}
showPhotoStats={event.showPhotoStats}
showSubmitterStats={event.showSubmitterStats}
showNoteStats={event.showNoteStats}
/>
</div>
),
},
]
@@ -81,7 +98,7 @@ export default async function EventDashboardPage({
{
id: "notes",
label: "Notes",
content: <EventNotes eventId={event.id} />,
content: <EventNotes eventId={event.id} canManage={canSettings} />,
},
]
: []),
@@ -103,15 +120,16 @@ export default async function EventDashboardPage({
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h1 className="font-display text-3xl sm:text-4xl">{event.title}</h1>
<Badge variant="secondary">{event.status}</Badge>
<Badge variant="secondary">{eventStatusLabel(event)}</Badge>
{event.listed ? <Badge variant="outline">Listed</Badge> : null}
{event.galleryReleasedAt ? (
{galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt) ? (
<Badge variant="outline">Gallery live</Badge>
) : (
<Badge variant="outline">Gallery held</Badge>
)}
</div>
<p className="truncate text-sm text-muted-foreground">{event.guestUrl}</p>
{event.location ? <p className="text-sm text-muted-foreground">{event.location}</p> : null}
</div>
<CopyGuestLink url={event.guestUrl} />
</div>
+11 -50
View File
@@ -1,59 +1,20 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { auth } from "@/server/auth";
import { getPlatformRole } from "@/server/roles";
import { createServerCaller } from "@/trpc/server";
import { GroupSwitcher } from "@/components/group-switcher";
import { DashboardTabBar } from "@/components/dashboard-tab-bar";
import { Button } from "@/components/ui/button";
import { BackendShell } from "@/components/backend-shell";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/sign-in?callbackURL=/dashboard");
}
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const caller = await createServerCaller();
const viewer = await caller.viewer.me();
const platformRole = await getPlatformRole(session.user.id);
if (!viewer.session) redirect("/sign-in?callbackURL=/dashboard");
return (
<>
<main className="page-pad mx-auto w-full max-w-6xl py-6 pb-24 sm:py-10 sm:pb-10">
<div className="mb-6 hidden items-center justify-between gap-3 sm:flex">
<nav className="flex items-center gap-1">
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard">Events</Link>
</Button>
{viewer.groups.length > 0 ? (
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard/people">People</Link>
</Button>
) : null}
{platformRole ? (
<Button asChild variant="ghost" size="sm">
<Link href="/admin">Admin</Link>
</Button>
) : null}
</nav>
<GroupSwitcher
groups={viewer.groups}
activeGroupId={viewer.activeGroupId}
/>
</div>
<div className="mb-5 sm:hidden">
<GroupSwitcher
groups={viewer.groups}
activeGroupId={viewer.activeGroupId}
/>
</div>
{children}
</main>
<DashboardTabBar showAdmin={Boolean(platformRole)} />
</>
<BackendShell
area="workspace"
groups={viewer.groups}
activeGroupId={viewer.activeGroupId}
showAdmin={Boolean(viewer.platformRole)}
>
{children}
</BackendShell>
);
}
+12 -2
View File
@@ -1,6 +1,9 @@
import Link from "next/link";
import { eventStatusLabel } from "@/lib/event-status";
import { ArrowRightIcon } from "lucide-react";
import { createServerCaller } from "@/trpc/server";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -67,6 +70,10 @@ export default async function DashboardPage() {
className="group min-w-0 rounded-xl focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<Card className="h-full transition-transform duration-200 ease-out group-hover:-translate-y-0.5 group-hover:shadow-md motion-reduce:transition-none motion-reduce:group-hover:translate-y-0">
{event.bannerUrl ? (
<img src={event.bannerUrl} alt="" width={800} height={300} loading="lazy" decoding="async"
className="aspect-[8/3] w-full object-cover" />
) : null}
<CardHeader>
<CardTitle className="truncate text-2xl font-semibold tracking-tight">
{event.title}
@@ -75,10 +82,13 @@ export default async function DashboardPage() {
</CardHeader>
<CardContent className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap gap-2">
<Badge variant="secondary">{event.status}</Badge>
<Badge variant="secondary">{eventStatusLabel(event)}</Badge>
{event.listed ? <Badge variant="outline">Listed</Badge> : null}
</div>
<span className="text-sm text-primary">Open</span>
<span className={buttonVariants({ variant: "outline" })}>
Open
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</span>
</CardContent>
</Card>
</Link>
@@ -1,5 +1,7 @@
"use client";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useState } from "react";
import { toast } from "sonner";
import type { GroupRole } from "@album/contracts";
@@ -103,14 +105,10 @@ export function GroupPeople({
onChange={(event) => setEmail(event.target.value)}
className="max-w-xs"
/>
<select
className="rounded-md border bg-background px-2 py-1.5 text-sm"
value={role}
onChange={(event) => setRole(event.target.value as GroupRole)}
>
<option value="member">member</option>
<option value="owner">owner</option>
</select>
<Select value={role} onValueChange={(value) => setRole(value as GroupRole)}>
<SelectTrigger aria-label="Group role"><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup><SelectItem value="member">Member</SelectItem><SelectItem value="owner">Owner</SelectItem></SelectGroup></SelectContent>
</Select>
<Button type="submit" disabled={setMember.isPending}>
Add existing user
</Button>
+2 -2
View File
@@ -18,7 +18,7 @@ import {
} from "@/components/ui/dialog";
export function GuestGallery({ slug }: { slug: string }) {
const gallery = api.event.gallery.useQuery(slug);
const gallery = api.event.gallery.useQuery(slug, { refetchInterval: 10_000 });
const [active, setActive] = useState<string | null>(null);
if (gallery.isLoading) {
@@ -40,7 +40,7 @@ export function GuestGallery({ slug }: { slug: string }) {
<EmptyHeader>
<EmptyTitle>No photos in the gallery yet</EmptyTitle>
<EmptyDescription>
Photos will appear here after the event people release the gallery.
Photos will appear here when they are processed and approved for publishing.
</EmptyDescription>
</EmptyHeader>
</Empty>
+42 -6
View File
@@ -2,7 +2,9 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { ChevronDownIcon, UploadIcon } from "lucide-react";
import { ChevronDownIcon, SendIcon, UploadIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import type { PublishingPolicy } from "@album/contracts";
import { cn } from "@/lib/utils";
import { MAX_PHOTO_BYTES } from "@album/contracts";
import { api } from "@/trpc/react";
@@ -31,14 +33,19 @@ function guestKey(slug: string, field: string) {
export function GuestUpload({
slug,
uploadEnabled,
notesEnabled,
notesPolicy,
}: {
slug: string;
uploadEnabled: boolean;
notesEnabled: boolean;
notesPolicy: PublishingPolicy;
}) {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [note, setNote] = useState("");
const [notify, setNotify] = useState(true);
const [notify, setNotify] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [dragging, setDragging] = useState(false);
const [queue, setQueue] = useState<QueueItem[]>([]);
@@ -55,6 +62,7 @@ export function GuestUpload({
setName(storedName);
setEmail(storedEmail);
setNote(storedNote);
setNotify(localStorage.getItem(guestKey(slug, "notify")) === "true");
if (storedName || storedEmail || storedNote) setDetailsOpen(true);
}, [slug]);
@@ -63,11 +71,26 @@ export function GuestUpload({
[queue],
);
async function sendNote(detailsOnly = false) {
try {
await ensureGuest.mutateAsync({ eventSlug: slug, displayName: name.trim() || undefined,
email: email.trim() || undefined, notifyWhenReady: Boolean(email.trim()) && notify, note: detailsOnly ? undefined : note.trim() });
localStorage.setItem(guestKey(slug, "name"), name.trim());
localStorage.setItem(guestKey(slug, "email"), email.trim());
localStorage.setItem(guestKey(slug, "notify"), String(notify));
if (!detailsOnly) { localStorage.removeItem(guestKey(slug, "note")); setNote(""); }
toast.success(detailsOnly ? "Guest details saved" : "Note sent");
router.refresh();
} catch (error) { toast.error(error instanceof Error ? error.message : "Could not send note"); }
}
async function uploadFiles(files: File[]) {
const accepted = files.filter(isAllowedPhoto);
if (!uploadEnabled || busy) return;
if (accepted.length !== files.length) {
toast.error("Some files were skipped. Use JPEG, PNG, WebP, or HEIC under 25 MB.");
}
if (!accepted.length) return;
const items: QueueItem[] = accepted.map((file) => ({
id: crypto.randomUUID(),
name: file.name,
@@ -81,6 +104,7 @@ export function GuestUpload({
localStorage.setItem(guestKey(slug, "name"), trimmedName);
localStorage.setItem(guestKey(slug, "email"), trimmedEmail);
localStorage.setItem(guestKey(slug, "note"), trimmedNote);
localStorage.setItem(guestKey(slug, "notify"), String(notify));
try {
await ensureGuest.mutateAsync({
@@ -138,6 +162,7 @@ export function GuestUpload({
}
}
await utils.event.gallery.invalidate(slug);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not start upload");
setQueue((current) =>
@@ -150,17 +175,17 @@ export function GuestUpload({
}
}
if (!uploadEnabled) {
if (!uploadEnabled && !notesEnabled) {
return (
<Alert>
<AlertDescription>Uploads are closed for this event.</AlertDescription>
<AlertDescription>Submissions are closed for this event.</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-4">
<label
{uploadEnabled ? <label
className={cn(
"flex min-h-52 cursor-pointer flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-primary/35 bg-card px-5 py-10 text-center shadow-sm transition-all duration-200 hover:border-primary/60 hover:bg-accent/50 motion-reduce:transition-none",
dragging && "scale-[1.01] border-primary bg-accent/60 shadow-[0_0_0_6px] shadow-primary/15 motion-reduce:scale-100",
@@ -212,7 +237,7 @@ export function GuestUpload({
{busy ? "Uploading…" : "Choose photos"}
</span>
</Button>
</label>
</label> : null}
{queue.length > 0 ? (
<ul className="flex flex-col gap-3" aria-live="polite">
{queue.map((item) => (
@@ -289,7 +314,18 @@ export function GuestUpload({
placeholder="Congratulations — enjoy the day."
maxLength={2000}
/>
<FieldDescription>
{notesPolicy === "automatic" ? "Your note and name will be published automatically."
: notesPolicy === "approved" ? "Your note and name may be published after organizer approval."
: "Your note is private to the event organizers."}
{" "}You can send a note without photos. Sending another replaces your previous note.
</FieldDescription>
</Field>
<Button type="button" disabled={!notesEnabled || !note.trim() || ensureGuest.isPending || busy} onClick={() => void sendNote()}>
<SendIcon data-icon="inline-start" aria-hidden="true" />
{ensureGuest.isPending ? "Sending…" : "Send note"}
</Button>
<Button type="button" variant="outline" disabled={!email.trim() || ensureGuest.isPending || busy} onClick={() => void sendNote(true)}>Save details without a photo or note</Button>
</FieldGroup>
</div>
</details>
+41 -5
View File
@@ -1,7 +1,10 @@
import { notFound } from "next/navigation";
import { MapPinIcon } from "lucide-react";
import { EventMap } from "@/components/event-map";
import { createServerCaller } from "@/trpc/server";
import { formatEventDate } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { galleryIsPublic } from "@/lib/publishing";
import { GuestGallery } from "./guest-gallery";
import { GuestUpload } from "./guest-upload";
@@ -20,11 +23,17 @@ export default async function EventPage({
}
const when = formatEventDate(event.startsAt);
const galleryLive = Boolean(event.galleryReleasedAt);
const galleryLive = galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt);
const community = await caller.event.community(slug);
return (
<main className="page-pad mx-auto flex w-full max-w-4xl flex-col gap-8 py-8 sm:gap-10 sm:py-12">
<header className="reveal flex flex-col gap-3">
{event.bannerUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={event.bannerUrl} alt="" width={1600} height={600}
className="mb-3 aspect-[8/3] w-full rounded-2xl object-cover" />
) : null}
<p className="text-xs font-medium tracking-[0.22em] text-primary uppercase">
Guest gallery
</p>
@@ -32,18 +41,45 @@ export default async function EventPage({
{event.title}
</h1>
{when ? <p className="text-muted-foreground">{when}</p> : null}
{event.location ? (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<MapPinIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />{event.location}
</p>
) : null}
{event.description ? (
<p className="max-w-2xl text-muted-foreground">{event.description}</p>
<p className="max-w-2xl whitespace-pre-line text-muted-foreground">{event.description}</p>
) : null}
</header>
{Object.values(community.stats).some((value) => value !== null) ? (
<dl className="flex flex-wrap gap-6 rounded-xl border bg-card p-4">
{([["photos", "Images uploaded"], ["submitters", "Submitters"], ["notes", "Notes sent"]] as const).map(([key, label]) =>
community.stats[key] !== null ? <div key={key}><dt className="text-sm text-muted-foreground">{label}</dt><dd className="text-2xl font-semibold">{community.stats[key]}</dd></div> : null)}
</dl>
) : null}
{event.latitude !== null && event.longitude !== null ? (
<section aria-label="Event location" className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Getting Here</h2>
<EventMap latitude={event.latitude} longitude={event.longitude} location={event.location ?? event.title} />
</section>
) : null}
<section id="upload" className="reveal-2 scroll-mt-24 flex flex-col gap-4">
<h2 className="sr-only">Add a photo</h2>
<h2 className="sr-only">Add photos or send a note</h2>
<GuestUpload
slug={event.slug}
uploadEnabled={event.uploadEnabled && event.status === "published"}
notesEnabled={event.status === "published" && (!event.submissionsOpenAt || event.submissionsOpenAt <= new Date())}
notesPolicy={event.notesPolicy}
/>
</section>
<Separator />
{event.notesPolicy !== "never" && community.notes.length > 0 ? (
<section className="flex flex-col gap-4" aria-label="Guest notes">
<h2 className="text-2xl font-semibold">Guest notes</h2>
{community.notes.map((note) => <blockquote key={note.id} className="rounded-xl border bg-card p-4">
<p className="whitespace-pre-wrap">{note.note}</p><footer className="mt-2 text-sm text-muted-foreground">{note.displayName ?? "Anonymous"}</footer>
</blockquote>)}
</section>
) : null}
{event.galleryPolicy !== "never" ? <><Separator />
<section className="reveal-3 flex flex-col gap-4">
<h2 className="text-2xl font-semibold tracking-tight">Gallery</h2>
{galleryLive ? (
@@ -53,7 +89,7 @@ export default async function EventPage({
Photos will appear here when the event people release the gallery.
</p>
)}
</section>
</section></> : null}
</main>
);
}
+9 -4
View File
@@ -20,8 +20,9 @@
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--font-playfair), ui-serif, Georgia, serif;
--font-display: var(--font-playfair), ui-serif, Georgia, serif;
--font-heading: var(--font-funnel-display), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-funnel-display), ui-sans-serif, system-ui, sans-serif;
--font-brand: var(--font-geologica), ui-sans-serif, system-ui, sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -142,6 +143,10 @@
}
@layer utilities {
.brand-wordmark {
font-variation-settings: "SHRP" 70;
}
.page-pad {
padding-inline: max(1.25rem, env(safe-area-inset-left))
max(1.25rem, env(safe-area-inset-right));
@@ -262,14 +267,14 @@
}
.font-display {
font-family: var(--font-playfair), ui-serif, Georgia, serif;
font-family: var(--font-funnel-display), ui-sans-serif, system-ui, sans-serif;
text-wrap: balance;
}
h1,
h2,
h3 {
font-family: var(--font-playfair), ui-serif, Georgia, serif;
font-family: var(--font-funnel-display), ui-sans-serif, system-ui, sans-serif;
text-wrap: balance;
}
+14 -5
View File
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Inter, Playfair_Display } from "next/font/google";
import { Funnel_Display, Geologica, Inter } from "next/font/google";
import { TRPCReactProvider } from "@/trpc/react";
import { ThemeProvider } from "@/components/theme-provider";
import { SiteHeader } from "@/components/site-header";
@@ -13,14 +13,23 @@ const inter = Inter({
display: "swap",
});
const playfair = Playfair_Display({
variable: "--font-playfair",
const funnelDisplay = Funnel_Display({
variable: "--font-funnel-display",
subsets: ["latin"],
display: "swap",
weight: ["600", "700", "800"],
weight: "variable",
});
const geologica = Geologica({
variable: "--font-geologica",
subsets: ["latin"],
display: "swap",
weight: "variable",
axes: ["SHRP"],
});
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"),
applicationName: BRAND_NAME,
title: {
default: BRAND_TITLE,
@@ -44,7 +53,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${inter.variable} ${playfair.variable} font-sans`}
className={`${inter.variable} ${funnelDisplay.variable} ${geologica.variable} font-sans`}
suppressHydrationWarning
>
<body>
+7 -2
View File
@@ -101,7 +101,8 @@ export default async function HomePage() {
Shared albums for real life
</div>
<h1 className="font-display text-[clamp(3.4rem,7vw,5.8rem)] leading-[0.92] font-semibold tracking-[-0.055em]">
Every angle.<br /><span className="text-primary">One shared album.</span>
<span className="block whitespace-nowrap">Every angle.</span>
<span className="block text-primary">One shared album.</span>
</h1>
<p className="max-w-lg text-lg leading-7 text-muted-foreground sm:text-xl sm:leading-8">
Give guests one link to add their photos. Review everything in one place, share the best, and keep every original.
@@ -166,8 +167,12 @@ export default async function HomePage() {
{listed.map((event) => (
<Link key={event.id} href={`/e/${event.slug}`} className="group block min-w-0 rounded-xl focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none">
<Card className="h-full transition-transform duration-200 ease-out group-hover:-translate-y-0.5 group-hover:shadow-md motion-reduce:transition-none motion-reduce:group-hover:translate-y-0">
{event.bannerUrl ? (
<img src={event.bannerUrl} alt="" width={800} height={300} loading="lazy" decoding="async"
className="aspect-[8/3] w-full object-cover" />
) : null}
<CardHeader>
<div className="mb-2 flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground"><BrandMark className="size-5" /></div>
{!event.bannerUrl ? <div className="mb-2 flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground"><BrandMark className="size-5" /></div> : null}
<CardTitle className="truncate text-xl font-semibold tracking-tight">{event.title}</CardTitle>
<CardDescription>{formatEventDate(event.startsAt) ?? "Open gallery"}</CardDescription>
</CardHeader>