Refine workspaces and event publishing; harden uploads and email delivery
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 recipient’s 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 device’s 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 3–64 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user