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>
@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import { Check, ChevronDown, Globe, Images, ShieldCheck } from "lucide-react";
import { topBarControlClass } from "@/components/top-bar-control";
import { cn } from "@/lib/utils";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
const applications = {
"/": { label: "Public Site", description: "Public events and shared photo galleries", icon: Globe },
"/dashboard": { label: "Albums", description: "Events, photo review, and workspace access", icon: Images },
"/admin": { label: "Administration", description: "Users, invitations, and deployment policies", icon: ShieldCheck },
};
export function ApplicationSwitcher({ links, pathname }: {
links: { href: string; label: string }[];
pathname: string;
}) {
const currentHref = pathname.startsWith("/admin") ? "/admin" : pathname.startsWith("/dashboard") ? "/dashboard" : "/";
const current = applications[currentHref];
const CurrentIcon = current.icon;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" aria-label={`Application: ${current.label}`}
className={cn(topBarControlClass, "w-11 shrink-0 justify-center px-0 sm:w-auto sm:justify-start sm:px-3")}>
<CurrentIcon aria-hidden="true" className="size-4 text-primary" />
<span className="hidden sm:inline">{current.label}</span>
<ChevronDown aria-hidden="true" className="hidden size-3.5 text-muted-foreground sm:block" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuLabel>Applications</DropdownMenuLabel>
<DropdownMenuGroup>
{links.map((link) => {
const app = applications[link.href as keyof typeof applications];
if (!app) return null;
const active = link.href === currentHref;
const Icon = app.icon;
return (
<DropdownMenuItem key={link.href} asChild className="p-3">
<Link href={link.href} aria-current={active ? "location" : undefined}>
<span className="flex w-full min-w-0 flex-col gap-1">
<span className="flex items-center gap-3">
<Icon aria-hidden="true" className="text-primary" />
<strong className="min-w-0 flex-1">{app.label}</strong>
{active ? <Check aria-hidden="true" className="text-primary" /> : null}
</span>
<span className="pl-7 text-xs leading-5 text-muted-foreground">{app.description}</span>
</span>
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import type { WorkspaceGroup } from "@/components/group-switcher";
import { DoubleSidebarNavigation } from "@/components/double-sidebar-navigation";
import { activeNavigationItem, adminWorkspaces, albumWorkspaces } from "@/lib/workspace-navigation";
import { cn } from "@/lib/utils";
export function BackendShell({ children, area, groups, activeGroupId }: {
children: ReactNode;
area: "workspace" | "platform";
groups: WorkspaceGroup[];
activeGroupId: string | null;
showAdmin?: boolean;
}) {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
try { setCollapsed(localStorage.getItem("manyangles-sidebar-collapsed") === "true"); } catch {}
}, []);
function togglePanel() {
const next = !collapsed;
setCollapsed(next);
try { localStorage.setItem("manyangles-sidebar-collapsed", String(next)); } catch {}
}
const workspaces = area === "platform" ? adminWorkspaces : albumWorkspaces;
const applicationLabel = area === "platform" ? "Administration" : "Albums";
const activeItem = activeNavigationItem(workspaces, pathname);
const activeWorkspace = workspaces.find((workspace) => workspace.items.includes(activeItem!)) ?? workspaces[0]!;
const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0];
return (
<div className="min-h-[calc(100dvh-4rem)] bg-background">
<DoubleSidebarNavigation key={area} workspaces={workspaces} activeItem={activeItem}
applicationLabel={applicationLabel} panelCollapsed={collapsed} onTogglePanel={togglePanel}
groupName={area === "workspace" ? activeGroup?.name : undefined} />
<main className={cn("min-w-0", collapsed ? "lg:ml-[5.5rem]" : "lg:ml-[18.5rem]")}>
<div className="page-pad mx-auto w-full max-w-7xl py-5 lg:px-6 lg:py-6">
<nav aria-label="Breadcrumb" className="mb-5 flex items-center gap-2 text-xs text-muted-foreground">
<Link href={area === "platform" ? "/admin" : "/dashboard"} className="hover:text-foreground">{applicationLabel}</Link>
<ChevronRight aria-hidden="true" className="size-3" />
<span>{activeWorkspace.label}</span>
{activeItem && activeItem.label !== activeWorkspace.label ? <><ChevronRight aria-hidden="true" className="size-3" /><span>{activeItem.label}</span></> : null}
</nav>
{children}
</div>
</main>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { UploadIcon } from "lucide-react";
import { allowedImageTypeSchema, MAX_PHOTO_BYTES } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
export function BannerUpload({ eventId, selectedId, onSelect }: {
eventId: string;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const input = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [bannerId, setBannerId] = useState<string | null>(selectedId);
const create = api.banners.create.useMutation();
const complete = api.banners.complete.useMutation();
const status = api.banners.status.useQuery({ eventId, bannerId: bannerId ?? "" }, {
enabled: Boolean(bannerId),
refetchInterval: (query) => ["ready", "failed"].includes(query.state.data?.status ?? "") ? false : 1500,
retry: false,
});
async function upload(file: File) {
const mime = file.type || (/\.heic$/i.test(file.name) ? "image/heic" : /\.heif$/i.test(file.name) ? "image/heif" : "");
const parsed = allowedImageTypeSchema.safeParse(mime);
if (!parsed.success || file.size <= 0 || file.size > MAX_PHOTO_BYTES) {
toast.error("Choose a JPEG, PNG, WebP, or HEIC image up to 25 MB.");
return;
}
setUploading(true);
try {
const pending = await create.mutateAsync({ eventId, contentType: parsed.data, byteSize: file.size });
const response = await fetch(pending.uploadUrl, {
method: "PUT", body: file, headers: { "Content-Type": parsed.data }, signal: AbortSignal.timeout(120_000),
});
if (!response.ok) throw new Error("Banner upload failed. Please try again.");
await complete.mutateAsync({ eventId, bannerId: pending.bannerId });
setBannerId(pending.bannerId);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Banner upload failed");
} finally { setUploading(false); }
}
const processing = Boolean(bannerId) && status.data?.status !== "ready" && status.data?.status !== "failed" && !status.isError;
return (
<div className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<input ref={input} type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" aria-label="Upload a dedicated event banner"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void upload(file);
}} />
<Button type="button" variant="outline" disabled={uploading || processing} onClick={() => input.current?.click()}>
<UploadIcon data-icon="inline-start" />{uploading ? "Uploading…" : "Upload Banner"}
</Button>
{status.data?.status === "ready" && bannerId ? (
<Button type="button" variant="secondary" disabled={selectedId === bannerId} onClick={() => onSelect(bannerId)}>
{selectedId === bannerId ? "Banner Selected" : "Use Uploaded Banner"}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">A separate image just for this event's header. It won't appear in the gallery. Up to 25 MB.</p>
{processing ? <p role="status" className="text-sm text-muted-foreground">Preparing your banner You can keep editing while it processes.</p> : null}
{status.isError || status.data?.status === "failed" ? <p role="alert" className="text-sm text-destructive">Couldn't prepare this banner. Try uploading another image.</p> : null}
{status.data?.url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={status.data.url} alt="Uploaded banner preview" width={1200} height={450} className="aspect-[8/3] w-full rounded-lg object-cover" />
) : null}
</div>
);
}
+9 -6
View File
@@ -12,14 +12,14 @@ export function BrandMark({ className }: { className?: string }) {
<path
d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6"
stroke="currentColor"
strokeWidth="2.5"
strokeWidth="3"
strokeLinecap="round"
/>
<circle cx="21" cy="11" r="2" fill="currentColor" />
<circle cx="21" cy="11" r="2.25" fill="currentColor" />
<path
d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8"
stroke="currentColor"
strokeWidth="2.5"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
@@ -37,10 +37,13 @@ export function BrandLockup({
wordmarkClassName?: string;
}) {
return (
<span className={cn("inline-flex items-center gap-2", className)}>
<BrandMark className={cn("text-primary", markClassName)} />
<span className={cn("inline-flex h-7 items-center gap-2 text-foreground", className)}>
<BrandMark className={markClassName} />
<span
className={cn("font-heading font-semibold tracking-[-0.035em]", wordmarkClassName)}
className={cn(
"brand-wordmark font-brand text-[1.45rem] leading-7 font-bold tracking-[-0.04em]",
wordmarkClassName,
)}
translate="no"
>
{BRAND_NAME}
@@ -0,0 +1,259 @@
"use client";
// Adapted from RaceTix ticketing's DoubleSidebarNavigation.
import Link from "next/link";
import { Check, ChevronsLeft, ChevronsRight, ExternalLink, Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { NavigationItem, NavigationWorkspace } from "@/lib/workspace-navigation";
function groupBySection(items: NavigationItem[]) {
const groups = new Map<string | null, NavigationItem[]>();
for (const item of items) {
const key = item.section ?? null;
const list = groups.get(key) ?? [];
list.push(item);
groups.set(key, list);
}
return [...groups.entries()];
}
function groupIsActive(
workspace: NavigationWorkspace,
activeItem: NavigationItem | undefined,
) {
return !!activeItem && workspace.items.some((item) => item === activeItem);
}
function workspaceTarget(workspace: NavigationWorkspace) {
if (
workspace.href &&
workspace.items.some((item) => item.href === workspace.href)
) {
return workspace.href;
}
return workspace.items[0]?.href ?? "#";
}
const itemLinkClass = (active: boolean) =>
cn(
"flex min-h-11 min-w-0 items-center gap-2.5 rounded-md px-2.5 text-sm font-semibold transition-colors motion-reduce:transition-none",
active
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
);
function WorkspaceRail({
workspaces,
activeItem,
applicationLabel,
panelCollapsed,
onTogglePanel,
}: {
workspaces: NavigationWorkspace[];
activeItem: NavigationItem | undefined;
applicationLabel: string;
panelCollapsed: boolean;
onTogglePanel: () => void;
}) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<nav
aria-label={`${applicationLabel} workspaces`}
className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-2 py-3"
>
{workspaces.map((workspace) => {
const Icon = workspace.icon;
const active = groupIsActive(workspace, activeItem);
return (
<Link
key={workspace.label}
href={workspaceTarget(workspace)}
aria-current={active ? "location" : undefined}
title={workspace.label}
className={cn(
"group flex min-h-[4.25rem] flex-col items-center justify-center gap-1 rounded-lg px-1 text-center text-[0.65rem] font-bold leading-tight transition-colors motion-reduce:transition-none",
active
? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon aria-hidden="true" className="size-5 shrink-0" />
<span className="line-clamp-2 max-w-full text-balance">
{workspace.label}
</span>
</Link>
);
})}
</nav>
<Separator />
<div className="p-2">
<Button
type="button"
variant="ghost"
size="icon"
className="w-full"
onClick={onTogglePanel}
aria-label={
panelCollapsed
? "Show workspace navigation"
: "Hide workspace navigation"
}
title={
panelCollapsed
? "Show workspace navigation"
: "Hide workspace navigation"
}
>
{panelCollapsed ? (
<ChevronsRight aria-hidden="true" data-icon="inline-start" />
) : (
<ChevronsLeft aria-hidden="true" data-icon="inline-start" />
)}
</Button>
</div>
</div>
);
}
function WorkspacePanel({
workspace,
activeItem,
applicationLabel,
}: {
workspace: NavigationWorkspace;
activeItem: NavigationItem | undefined;
applicationLabel: string;
}) {
return (
<div className="flex h-full min-h-0 flex-col">
<header className="border-b px-4 py-4">
<p className="text-[0.65rem] font-black uppercase tracking-[0.18em] text-primary">
{applicationLabel} Workspace
</p>
<h2 className="mt-1 truncate text-base font-bold">{workspace.label}</h2>
<p className="mt-1 line-clamp-2 text-xs leading-4 text-muted-foreground">
{workspace.description}
</p>
</header>
<nav
aria-label={`${workspace.label} navigation`}
className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3"
>
{groupBySection(workspace.items).map(([section, items]) => (
<section key={section ?? "items"} aria-label={section ?? undefined}>
{section ? (
<h3 className="px-2.5 pb-1.5 text-[0.65rem] font-black uppercase tracking-[0.14em] text-muted-foreground/80">
{section}
</h3>
) : null}
<div className="flex flex-col gap-0.5">
{items.map((item) => {
const Icon = item.icon;
const active = item === activeItem;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
title={item.description}
className={itemLinkClass(active)}
>
<Icon
aria-hidden="true"
className={cn(
"size-4 shrink-0",
active && "text-primary",
)}
/>
<span className="min-w-0 flex-1 truncate">
{item.label}
</span>
</Link>
);
})}
</div>
</section>
))}
</nav>
</div>
);
}
export function DoubleSidebarNavigation({
workspaces, activeItem, applicationLabel, panelCollapsed, onTogglePanel, groupName,
}: {
workspaces: NavigationWorkspace[];
activeItem: NavigationItem | undefined;
applicationLabel: string;
panelCollapsed: boolean;
onTogglePanel: () => void;
groupName?: string;
}) {
const activeWorkspace = workspaces.find((workspace) => groupIsActive(workspace, activeItem)) ?? workspaces[0]!;
const CurrentIcon = activeItem?.icon ?? activeWorkspace.icon;
return (
<>
<div className="sticky top-16 z-30 flex h-14 items-center justify-between gap-2 border-b bg-background/95 px-4 backdrop-blur lg:hidden">
<div className="flex min-w-0 items-center gap-2">
<span className="rounded-md bg-primary/10 p-1.5 text-primary"><CurrentIcon aria-hidden="true" className="size-4" /></span>
<span className="truncate text-sm font-bold">{activeItem?.label ?? activeWorkspace.label}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open navigation"><Menu aria-hidden="true" data-icon="inline-start" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 max-w-[calc(100vw-2rem)]">
<DropdownMenuLabel>{groupName ?? applicationLabel} Workspaces</DropdownMenuLabel>
{workspaces.map((workspace) => (
<DropdownMenuGroup key={workspace.label}>
{workspace.items.length > 1 ? <DropdownMenuLabel>{workspace.label}</DropdownMenuLabel> : null}
{workspace.items.map((item) => {
const Icon = workspace.items.length === 1 ? workspace.icon : item.icon;
const active = item === activeItem;
return (
<DropdownMenuItem key={item.href} asChild className="min-h-11 p-3">
<Link href={item.href} aria-current={active ? "page" : undefined}>
<Icon aria-hidden="true" />
<span className="min-w-0 flex-1">{workspace.items.length === 1 ? workspace.label : item.label}</span>
{active ? <Check aria-hidden="true" /> : null}
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
))}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild className="min-h-11 p-3">
<Link href="/"><ExternalLink aria-hidden="true" />Public Site</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="fixed bottom-0 left-0 top-16 z-30 hidden w-[5.5rem] flex-col border-r bg-card text-card-foreground shadow-sm lg:flex">
<WorkspaceRail workspaces={workspaces} activeItem={activeItem} applicationLabel={applicationLabel}
panelCollapsed={panelCollapsed} onTogglePanel={onTogglePanel} />
</div>
{!panelCollapsed ? (
<aside aria-label="Workspace sidebar" className="fixed bottom-0 left-[5.5rem] top-16 z-20 hidden w-52 flex-col border-r bg-background/95 text-foreground shadow-sm backdrop-blur lg:flex">
<div className="min-h-0 flex-1"><WorkspacePanel workspace={activeWorkspace} activeItem={activeItem} applicationLabel={applicationLabel} /></div>
<Separator />
<div className="flex flex-col gap-2 p-3">
{groupName ? <p className="truncate px-2.5 text-xs font-semibold text-muted-foreground" title={groupName}>{groupName}</p> : null}
<Link href="/" className="flex min-h-11 items-center gap-2 rounded-md px-2.5 text-sm font-semibold text-muted-foreground hover:bg-muted hover:text-foreground">
<ExternalLink aria-hidden="true" className="size-4" />Public Site
</Link>
</div>
</aside>
) : null}
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { openStreetMapEmbedUrl } from "@/lib/maps";
export function EventMap({ latitude, longitude, location }: {
latitude: number;
longitude: number;
location: string;
}) {
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
return (
<div className="flex flex-col gap-2">
<iframe title={`Map of ${location}`} src={openStreetMapEmbedUrl(latitude, longitude)}
loading="lazy" referrerPolicy="no-referrer" className="h-64 w-full rounded-xl border bg-muted" />
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="underline underline-offset-4">© OpenStreetMap contributors</a>
<a href={url} target="_blank" rel="noreferrer" className="font-semibold underline underline-offset-4">Open Larger Map</a>
</div>
</div>
);
}
+9 -4
View File
@@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { ImageIcon, Settings2Icon, UsersIcon, StickyNoteIcon, ActivityIcon } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export type EventWorkspaceTab = {
@@ -17,22 +18,26 @@ export function EventWorkspace({
tabs: EventWorkspaceTab[];
}) {
const initial = tabs[0]?.id ?? "photos";
const icons = { photos: ImageIcon, settings: Settings2Icon, people: UsersIcon, notes: StickyNoteIcon, activity: ActivityIcon, audit: ActivityIcon };
return (
<div className="reveal flex flex-col gap-6 sm:gap-8">
{heading}
{tabs.length === 0 ? null : (
<Tabs defaultValue={initial} className="gap-5">
<TabsList className="sticky top-[calc(3.5rem+env(safe-area-inset-top))] z-30 h-11 w-full max-w-full justify-start overflow-x-auto bg-muted/90 backdrop-blur-md sm:top-[calc(4rem+env(safe-area-inset-top))] sm:h-10">
{tabs.map((tab) => (
<TabsList variant="line" aria-label="Event sections" className="w-full max-w-full justify-start gap-2 overflow-x-auto p-0 shadow-[inset_0_-1px_0_var(--border)] group-data-horizontal/tabs:h-12">
{tabs.map((tab) => {
const Icon = icons[tab.id as keyof typeof icons];
return (
<TabsTrigger
key={tab.id}
value={tab.id}
className="tap-target flex-none px-3"
className="h-full flex-none gap-2 rounded-none border-0 px-4 py-3 transition-colors data-[state=active]:text-primary after:bg-primary group-data-horizontal/tabs:after:bottom-0"
>
{Icon ? <Icon aria-hidden="true" /> : null}
{tab.label}
</TabsTrigger>
))}
); })}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="flex flex-col gap-4">
+31 -26
View File
@@ -1,44 +1,49 @@
"use client";
import { useRouter } from "next/navigation";
import { ImagesIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { topBarControlClass } from "@/components/top-bar-control";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function GroupSwitcher({
groups,
activeGroupId,
}: {
groups: { id: string; name: string }[];
export type WorkspaceGroup = {
id: string;
name: string;
slug: string;
role: "owner" | "member";
};
export function GroupSwitcher({ groups, activeGroupId, compact = false }: {
groups: WorkspaceGroup[];
activeGroupId: string | null;
compact?: boolean;
}) {
const router = useRouter();
const utils = api.useUtils();
const select = api.group.select.useMutation({
onSuccess: () => router.refresh(),
onSuccess: async () => {
await utils.invalidate();
router.push("/dashboard");
router.refresh();
},
onError: (error) => toast.error(error.message),
});
if (groups.length === 0) return null;
const value = activeGroupId ?? groups[0]?.id;
const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0]!;
return (
<Select
value={value}
onValueChange={(groupId) => select.mutate({ groupId })}
>
<SelectTrigger
aria-label="Active group"
className="tap-target w-full min-w-0 max-w-full sm:w-auto sm:max-w-56"
>
<SelectValue placeholder="Group" />
<Select value={activeGroup.id} disabled={select.isPending}
onValueChange={(groupId) => { if (groupId !== activeGroup.id) select.mutate({ groupId }); }}>
<SelectTrigger aria-label="Active workspace"
className={cn(topBarControlClass, "w-11 shrink-0 px-3 sm:w-52 [&>svg:last-child]:hidden sm:[&>svg:last-child]:block", !compact && "sm:w-full")}>
<ImagesIcon aria-hidden="true" className="shrink-0 text-primary" />
<SelectValue className="sr-only sm:not-sr-only">{activeGroup.name}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" align="start">
<SelectGroup>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
<SelectItem key={group.id} value={group.id} className="min-h-11">
{group.name}
</SelectItem>
))}
@@ -0,0 +1,82 @@
"use client";
import { useEffect, useId, useState } from "react";
import type { LocationSuggestion } from "@album/contracts";
import { api } from "@/trpc/react";
import { Input } from "@/components/ui/input";
import { EventMap } from "@/components/event-map";
export function LocationInput({ eventId, value, latitude, longitude, onChange }: {
eventId: string;
value: string;
latitude: number | null;
longitude: number | null;
onChange: (value: string, match: LocationSuggestion | null) => void;
}) {
const listId = useId();
const [query, setQuery] = useState(value);
const [focused, setFocused] = useState(false);
const [active, setActive] = useState(-1);
useEffect(() => {
const timer = setTimeout(() => setQuery(value.trim()), 500);
return () => clearTimeout(timer);
}, [value]);
const enabled = focused && latitude === null && query.length >= 3 && query === value.trim();
const results = api.manager.searchLocations.useQuery({ eventId, query }, {
enabled, staleTime: 86_400_000, retry: false, refetchOnWindowFocus: false,
});
const suggestions = enabled ? results.data ?? [] : [];
const open = enabled && suggestions.length > 0;
function choose(match: LocationSuggestion) {
onChange(match.address, match);
setFocused(false);
setActive(-1);
}
return (
<div className="flex flex-col gap-3">
<div className="relative">
<Input id="location" value={value} maxLength={300} placeholder="Search for a venue or address"
autoComplete="off" role="combobox" aria-autocomplete="list"
aria-controls={open ? listId : undefined} aria-expanded={open}
aria-activedescendant={open && active >= 0 ? `${listId}-${active}` : undefined}
onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
onChange={(event) => { onChange(event.target.value, null); setFocused(true); setActive(-1); }}
onKeyDown={(event) => {
if (event.key === "Escape") { setFocused(false); return; }
if (open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
setActive((index) => index < 0
? (event.key === "ArrowDown" ? 0 : suggestions.length - 1)
: (index + (event.key === "ArrowDown" ? 1 : suggestions.length - 1)) % suggestions.length);
}
if (open && event.key === "Enter") {
event.preventDefault();
if (active >= 0 && suggestions[active]) choose(suggestions[active]);
}
}} />
{open ? (
<div id={listId} role="listbox" aria-label="Matching locations"
className="absolute top-full z-10 mt-1 max-h-72 w-full overflow-y-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-md">
{suggestions.map((match, index) => (
<button key={`${match.latitude}:${match.longitude}:${match.address}`}
id={`${listId}-${index}`} type="button" role="option" tabIndex={-1}
aria-selected={index === active}
onMouseDown={(event) => event.preventDefault()} onClick={() => choose(match)}
className="flex min-h-11 w-full items-center rounded-md px-3 py-2 text-left text-sm hover:bg-accent aria-selected:bg-accent">
{match.address}
</button>
))}
</div>
) : null}
</div>
<p role="status" className="text-xs text-muted-foreground">
{enabled && results.isFetching ? "Finding locations…" :
enabled && results.isError ? results.error.message :
enabled && results.isSuccess && !suggestions.length ? "No matches found. Try a fuller address, or keep a text-only location." :
latitude !== null && longitude !== null ? "Location matched. Save the event to update its map." :
"Choose a matching address to add a map, or keep a text-only location."}
</p>
{latitude !== null && longitude !== null ? <EventMap latitude={latitude} longitude={longitude} location={value} /> : null}
</div>
);
}
+112 -67
View File
@@ -1,8 +1,27 @@
"use client";
import Link from "next/link";
import { MenuIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import {
ChevronDownIcon,
LogOutIcon,
MenuIcon,
UserRoundIcon,
} from "lucide-react";
import { ApplicationSwitcher } from "@/components/application-switcher";
import { topBarControlClass } from "@/components/top-bar-control";
import { cn } from "@/lib/utils";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
@@ -10,88 +29,114 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { SignOutButton } from "@/components/sign-out-button";
import { GroupSwitcher, type WorkspaceGroup } from "@/components/group-switcher";
import { ThemeToggle } from "@/components/theme-toggle";
import { BrandLockup } from "@/components/brand-mark";
import { BRAND_NAME } from "@/lib/brand";
type HeaderLink = {
href: string;
label: string;
};
type HeaderLink = { href: string; label: string };
export function SiteHeaderBar({
links,
primary,
signedIn,
}: {
function initials(name: string) {
return name.split(/\s+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase();
}
export function SiteHeaderBar({ links, primary, signedIn, user, groups = [], activeGroupId = null }: {
links: HeaderLink[];
primary?: HeaderLink | null;
signedIn: boolean;
user?: { name: string; email: string };
groups?: WorkspaceGroup[];
activeGroupId?: string | null;
}) {
const pathname = usePathname();
const router = useRouter();
const backend = pathname.startsWith("/dashboard") || pathname.startsWith("/admin");
return (
<header className="sticky top-0 z-40 border-b border-border/80 bg-card/82 pt-[env(safe-area-inset-top)] backdrop-blur-xl">
<div className="page-pad mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 sm:h-16">
<header className="sticky top-0 z-40 h-16 border-b border-border/80 bg-card/92 pt-[env(safe-area-inset-top)] shadow-[0_1px_0_color-mix(in_oklab,var(--foreground)_5%,transparent)] backdrop-blur-xl">
<div className="flex h-full w-full items-center gap-2 px-4 sm:gap-3 sm:px-5">
<Link
href="/"
className="text-xl"
href={pathname.startsWith("/admin") ? "/admin" : signedIn ? "/dashboard" : "/"}
className="flex h-11 shrink-0 items-center"
aria-label={`${BRAND_NAME} home`}
>
<BrandLockup markClassName="size-6" />
<BrandLockup markClassName="size-7" wordmarkClassName="hidden sm:inline" />
</Link>
<nav className="hidden items-center gap-1 sm:flex">
{links.map((link) => (
<Button key={link.href} asChild variant="ghost" size="sm">
<Link href={link.href}>{link.label}</Link>
</Button>
))}
{primary ? (
<Button asChild size="sm">
<Link href={primary.href}>{primary.label}</Link>
</Button>
) : null}
<ThemeToggle />
{signedIn ? <SignOutButton /> : null}
</nav>
<div className="flex items-center gap-1 sm:hidden">
<ThemeToggle />
<Sheet>
<SheetTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-lg"
className="tap-target"
aria-label="Open menu"
>
<MenuIcon aria-hidden="true" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[min(20rem,90vw)]">
<SheetHeader>
<SheetTitle className="text-2xl">
<BrandLockup />
</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-2 px-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{links.map((link) => (
<Button asChild key={link.href} variant="ghost" className="tap-target justify-start">
<Link href={link.href}>{link.label}</Link>
</Button>
))}
{primary ? (
<Button asChild className="tap-target justify-start">
<Link href={primary.href}>{primary.label}</Link>
</Button>
) : null}
{signedIn ? (
<div className="pt-2">
<SignOutButton />
{signedIn && backend && groups.length > 0 && !pathname.startsWith("/admin") ? (
<>
<span className="mx-1 h-7 w-px bg-border" />
<div className="min-w-0 max-w-56 flex-1">
<GroupSwitcher groups={groups} activeGroupId={activeGroupId} compact />
</div>
</>
) : null}
<div className="ml-auto flex items-center gap-1">
{signedIn ? (
<>
<ApplicationSwitcher links={links} pathname={pathname} />
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`Account menu for ${user?.name ?? "user"}`}
className={cn(topBarControlClass, "w-11 shrink-0 justify-center px-1.5 sm:w-auto sm:justify-start sm:px-3")}
>
<span className="grid size-8 place-items-center rounded-full border bg-background text-xs font-bold tracking-wider">
{user?.name ? initials(user.name) : <UserRoundIcon />}
</span>
<span className="hidden max-w-32 truncate text-sm font-semibold lg:block">{user?.name}</span>
<ChevronDownIcon aria-hidden="true" className="hidden size-3.5 text-muted-foreground sm:block" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Signed in</DropdownMenuLabel>
<div className="px-1.5 pb-2">
<p className="truncate text-sm font-semibold">{user?.name}</p>
<p className="truncate text-xs text-muted-foreground">{user?.email}</p>
</div>
) : null}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onSelect={async () => {
await authClient.signOut();
router.push("/");
router.refresh();
}}>
<LogOutIcon />
Sign out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<nav className="hidden items-center gap-1 sm:flex">
{links.map((link) => (
<Button key={link.href} asChild variant="ghost"><Link href={link.href}>{link.label}</Link></Button>
))}
{primary ? <Button asChild><Link href={primary.href}>{primary.label}</Link></Button> : null}
<ThemeToggle />
</nav>
</SheetContent>
</Sheet>
<div className="flex items-center gap-1 sm:hidden">
<ThemeToggle />
<Sheet>
<SheetTrigger asChild>
<Button type="button" variant="outline" size="icon" aria-label="Open menu"><MenuIcon /></Button>
</SheetTrigger>
<SheetContent side="right" className="w-[min(20rem,90vw)]">
<SheetHeader><SheetTitle><BrandLockup /></SheetTitle></SheetHeader>
<nav className="flex flex-col gap-2 px-4">
{links.map((link) => <Button asChild key={link.href} variant="ghost" className="justify-start"><Link href={link.href}>{link.label}</Link></Button>)}
{primary ? <Button asChild className="justify-start"><Link href={primary.href}>{primary.label}</Link></Button> : null}
</nav>
</SheetContent>
</Sheet>
</div>
</>
)}
</div>
</div>
</header>
+13 -15
View File
@@ -1,21 +1,20 @@
import { headers } from "next/headers";
import { auth } from "@/server/auth";
import { getPlatformRole } from "@/server/roles";
import { getDeploymentSettings } from "@/server/settings";
import { createServerCaller } from "@/trpc/server";
import { SiteHeaderBar } from "@/components/site-header-bar";
export async function SiteHeader() {
const session = await auth.api.getSession({ headers: await headers() });
const platformRole = session ? await getPlatformRole(session.user.id) : null;
const settings = await getDeploymentSettings();
const viewer = await (await createServerCaller()).viewer.me();
if (session) {
if (viewer.session) {
return (
<SiteHeaderBar
signedIn
user={{ name: viewer.session.user.name, email: viewer.session.user.email }}
groups={viewer.groups}
activeGroupId={viewer.activeGroupId}
links={[
{ href: "/dashboard", label: "Dashboard" },
...(platformRole ? [{ href: "/admin", label: "Admin" }] : []),
{ href: "/", label: "Public site" },
{ href: "/dashboard", label: "Workspace" },
...(viewer.platformRole ? [{ href: "/admin", label: "Administration" }] : []),
]}
/>
);
@@ -25,11 +24,10 @@ export async function SiteHeader() {
<SiteHeaderBar
signedIn={false}
links={[{ href: "/sign-in", label: "Sign in" }]}
primary={
settings.openSignup
? { href: "/sign-up", label: "Host an event" }
: { href: "/sign-up", label: "Have an invite?" }
}
primary={{
href: "/sign-up",
label: viewer.openSignup ? "Host an event" : "Have an invite?",
}}
/>
);
}
+6 -1
View File
@@ -4,7 +4,12 @@ import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
+56 -14
View File
@@ -2,30 +2,72 @@
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { MoonIcon, SunIcon } from "lucide-react";
import { CheckIcon, MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { topBarControlClass } from "@/components/top-bar-control";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const choices = [
{ value: "system", label: "System", description: "Match this device", icon: MonitorIcon },
{ value: "light", label: "Light", description: "Always use light mode", icon: SunIcon },
{ value: "dark", label: "Dark", description: "Always use dark mode", icon: MoonIcon },
] as const;
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const dark = mounted && resolvedTheme === "dark";
const selected = mounted ? (theme ?? "system") : "system";
const SelectedIcon = choices.find((choice) => choice.value === selected)?.icon ?? MonitorIcon;
return (
<Button
type="button"
variant="ghost"
size="icon-lg"
className="tap-target"
aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
disabled={!mounted}
onClick={() => setTheme(dark ? "light" : "dark")}
>
{dark ? <SunIcon /> : <MoonIcon />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(topBarControlClass, "justify-center px-0")}
aria-label={`Theme: ${selected}`}
>
<SelectedIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
<DropdownMenuGroup>
{choices.map((choice) => {
const Icon = choice.icon;
const active = selected === choice.value;
return (
<DropdownMenuItem
key={choice.value}
className="min-h-12 gap-2.5"
onSelect={() => setTheme(choice.value)}
>
<Icon />
<span className="min-w-0 flex-1">
<span className="block font-medium">{choice.label}</span>
<span className="block text-xs text-muted-foreground">{choice.description}</span>
</span>
{active ? <CheckIcon className="text-primary" /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,4 @@
// Shared by the application, group, appearance, and account menus.
// Adapted from RaceTix's ticketing top bar; Manyangles uses 44px controls.
export const topBarControlClass =
"flex h-11 min-w-0 items-center gap-2 rounded-md border border-transparent bg-transparent px-3 text-sm font-semibold outline-none transition-[border-color,background-color,box-shadow] hover:border-border hover:bg-muted focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring/25 data-[state=open]:border-border data-[state=open]:bg-muted";
+3 -3
View File
@@ -21,16 +21,16 @@ const buttonVariants = cva(
},
size: {
default:
"h-10 gap-2 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
"h-11 gap-2 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-9 gap-1.5 rounded-[min(var(--radius-md),12px)] px-3 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-12 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4",
icon: "size-10",
icon: "size-11",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-11",
"icon-lg": "size-12",
},
},
defaultVariants: {
+1 -1
View File
@@ -7,7 +7,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-10 w-full min-w-0 rounded-lg border border-input bg-card/55 px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"h-11 w-full min-w-0 rounded-lg border border-input bg-card/55 px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
@@ -0,0 +1,18 @@
import type { ComponentProps } from "react";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "cn";
export function NativeSelect({ className, ...props }: ComponentProps<"select">) {
return (
<div className="relative inline-grid min-w-0 items-center">
<select
{...props}
className={cn(
"h-11 w-full appearance-none rounded-lg border border-input bg-background py-2 pl-3 pr-9 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50",
className,
)}
/>
<ChevronDownIcon aria-hidden="true" className="pointer-events-none absolute right-3 size-4 text-muted-foreground" />
</div>
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-card/55 py-2 pr-3 pl-3 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-10 data-[size=sm]:h-9 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-card/55 py-2 pr-3 pl-3 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 data-[size=sm]:h-9 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
+25
View File
@@ -0,0 +1,25 @@
import { expect, test } from "bun:test";
import { effectiveEvent, notesAreVisible } from "./event-lifecycle";
import { galleryIsPublic } from "./publishing";
const now = new Date("2026-09-09T12:00:00Z");
const before = new Date(now.getTime() - 1000);
const after = new Date(now.getTime() + 1000);
const event = { status: "draft" as const, uploadEnabled: true, publishAt: null, submissionsOpenAt: null, submissionsCloseAt: null, completedAt: null };
test("scheduled publication opens exactly at its boundary and never exposes drafts through closing", () => {
expect(effectiveEvent({ ...event, publishAt: after }, now).status).toBe("draft");
expect(effectiveEvent({ ...event, publishAt: now }, now).status).toBe("published");
expect(effectiveEvent({ ...event, submissionsCloseAt: before }, now).status).toBe("draft");
});
test("submission windows gate uploads and completion closes them", () => {
expect(effectiveEvent({ ...event, status: "published", submissionsOpenAt: after }, now).uploadEnabled).toBe(false);
expect(effectiveEvent({ ...event, publishAt: before, submissionsCloseAt: now }, now).status).toBe("closed");
expect(effectiveEvent({ ...event, status: "published", completedAt: before }, now).uploadEnabled).toBe(false);
});
test("visibility schedules never bypass never-public or expose content early", () => {
expect(galleryIsPublic("approved", before, after, now)).toBe(false);
expect(galleryIsPublic("approved", null, now, now)).toBe(true);
expect(galleryIsPublic("never", before, before, now)).toBe(false);
expect(notesAreVisible("automatic", after, now)).toBe(false);
expect(notesAreVisible("approved", now, now)).toBe(true);
expect(notesAreVisible("never", before, now)).toBe(false);
});
+16
View File
@@ -0,0 +1,16 @@
export function effectiveEvent<T extends {
status: "draft" | "published" | "closed";
uploadEnabled: boolean;
publishAt: Date | null;
submissionsOpenAt: Date | null;
submissionsCloseAt: Date | null;
completedAt: Date | null;
}>(event: T, now = new Date()): Omit<T, "status" | "uploadEnabled"> & { status: "draft" | "published" | "closed"; uploadEnabled: boolean } {
const publicStatus = event.publishAt ? (event.publishAt <= now ? (event.status === "draft" ? "published" : event.status) : "draft") : event.status;
const status = publicStatus === "draft" ? "draft" : event.completedAt || (event.submissionsCloseAt && event.submissionsCloseAt <= now) ? "closed" : publicStatus;
return { ...event, status, uploadEnabled: event.uploadEnabled && status === "published" && (!event.submissionsOpenAt || event.submissionsOpenAt <= now) };
}
export function notesAreVisible(policy: string, visibleAt: Date | null, now = new Date()) {
return policy !== "never" && (!visibleAt || visibleAt <= now);
}
+7
View File
@@ -0,0 +1,7 @@
import { effectiveEvent } from "./event-lifecycle";
export function eventStatusLabel(event: Parameters<typeof effectiveEvent>[0], now = new Date()) {
if (event.completedAt) return "Completed";
if (event.publishAt && event.publishAt > now) return "Scheduled";
const current = effectiveEvent(event, now);
return current.status === "draft" ? "Draft" : current.status === "closed" ? "Closed" : current.uploadEnabled ? "Open" : "Published";
}
+11
View File
@@ -0,0 +1,11 @@
// Same OpenStreetMap embed used by RaceTix.
export function openStreetMapEmbedUrl(latitude: number, longitude: number) {
const offset = 0.008;
const query = new URLSearchParams({
bbox: [Math.max(-180, longitude - offset), Math.max(-90, latitude - offset),
Math.min(180, longitude + offset), Math.min(90, latitude + offset)].join(","),
layer: "mapnik",
marker: `${latitude},${longitude}`,
});
return `https://www.openstreetmap.org/export/embed.html?${query}`;
}
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { galleryIsPublic } from "./publishing";
import { updateEventInputSchema } from "@album/contracts";
test("gallery publishing respects never, automatic, and manual release", () => {
expect(galleryIsPublic("never", new Date())).toBe(false);
expect(galleryIsPublic("never", null)).toBe(false);
expect(galleryIsPublic("automatic", null)).toBe(true);
expect(galleryIsPublic("approved", null)).toBe(false);
expect(galleryIsPublic("approved", new Date())).toBe(true);
});
test("publishing settings reject invalid modes and preserve omitted preferences", () => {
const eventId = "7fa618fc-3fcb-4fd4-a0af-03f14124cccc";
expect(updateEventInputSchema.safeParse({ eventId, notesPolicy: "everyone" }).success).toBe(false);
expect(updateEventInputSchema.parse({ eventId }).galleryPolicy).toBeUndefined();
expect(updateEventInputSchema.parse({ eventId, notesPolicy: "automatic", showPhotoStats: false }).showPhotoStats).toBe(false);
});
+5
View File
@@ -0,0 +1,5 @@
import type { PublishingPolicy } from "@album/contracts";
export function galleryIsPublic(policy: PublishingPolicy, releasedAt: Date | null, visibleAt: Date | null = null, now = new Date()) {
return policy !== "never" && (!visibleAt || visibleAt <= now) && (policy === "automatic" || releasedAt !== null || (visibleAt !== null && visibleAt <= now));
}
+6
View File
@@ -1,7 +1,13 @@
import { describe, expect, test } from "bun:test";
import { slugify } from "./slug";
import { eventSlugSchema } from "@album/contracts";
describe("slugify", () => {
test("keeps long generated slugs valid when truncation lands on a hyphen", () => {
const slug = slugify(`${"a".repeat(63)} next word`);
expect(slug).toBe("a".repeat(63));
expect(eventSlugSchema.safeParse(slug).success).toBe(true);
});
test("lowercases and hyphenates titles", () => {
expect(slugify("Summer Block Party")).toBe("summer-block-party");
});
+2 -1
View File
@@ -5,6 +5,7 @@ export function slugify(input: string) {
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
.slice(0, 64)
.replace(/-+$/, "");
return slug.length >= 3 ? slug : `event-${crypto.randomUUID().slice(0, 8)}`;
}
+51
View File
@@ -0,0 +1,51 @@
import { CalendarDays, Images, Users, ShieldCheck, Settings } from "lucide-react";
import type { LucideIcon } from "lucide-react";
export type NavigationItem = {
label: string;
href: string;
icon: LucideIcon;
description?: string;
section?: string;
matchPaths?: string[];
};
export type NavigationWorkspace = {
label: string;
description: string;
icon: LucideIcon;
href?: string;
items: NavigationItem[];
};
export const albumWorkspaces: NavigationWorkspace[] = [
{
label: "Events", description: "Shared albums, guest uploads, and photo review.",
icon: Images, href: "/dashboard",
items: [{ label: "All Events", href: "/dashboard", icon: CalendarDays, matchPaths: ["/dashboard/events"] }],
},
{
label: "People", description: "Manage the people who help run your events.",
icon: Users, href: "/dashboard/people",
items: [{ label: "Workspace Access", href: "/dashboard/people", icon: Users }],
},
];
export const adminWorkspaces: NavigationWorkspace[] = [
{
label: "Platform", description: "Users, workspace access, and invitations.",
icon: ShieldCheck, href: "/admin",
items: [{ label: "Overview", href: "/admin", icon: ShieldCheck }],
},
{
label: "Settings", description: "Signup and event creation policies for Manyangles.",
icon: Settings, href: "/admin/settings",
items: [{ label: "Deployment Settings", href: "/admin/settings", icon: Settings }],
},
];
export function activeNavigationItem(workspaces: NavigationWorkspace[], pathname: string) {
return workspaces.flatMap((workspace) => workspace.items)
.filter((item) => item.href === pathname || item.matchPaths?.some((path) => pathname === path || pathname.startsWith(path + "/")))
.sort((a, b) => b.href.length - a.href.length)[0];
}
+2
View File
@@ -1,5 +1,6 @@
import { createTRPCRouter, publicProcedure } from "./trpc";
import { eventRouter } from "./routers/event";
import { bannersRouter } from "./routers/banners";
import { guestRouter } from "./routers/guest";
import { groupRouter } from "./routers/group";
import { invitesRouter } from "./routers/invites";
@@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
})),
viewer: viewerRouter,
event: eventRouter,
banners: bannersRouter,
guest: guestRouter,
photos: photosRouter,
group: groupRouter,
@@ -0,0 +1,48 @@
import { randomUUID } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { eventBanners, getDb } from "@album/database";
import { bannerInputSchema, createBannerInputSchema, MAX_PHOTO_BYTES } from "@album/contracts";
import { createPresignedPutUrl, createPresignedGetUrl, headObject } from "@album/storage";
import { createTRPCRouter, protectedProcedure, loadEventAccess, requireEventPermission, EVENT_PERMISSIONS } from "../trpc";
import { getPlatformRole } from "@/server/roles";
import { consumeRateLimit } from "@/server/rate-limit";
async function authorize(userId: string, eventId: string) {
const { access } = await loadEventAccess(userId, eventId, await getPlatformRole(userId));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
}
export const bannersRouter = createTRPCRouter({
create: protectedProcedure.input(createBannerInputSchema).mutation(async ({ ctx, input }) => {
await authorize(ctx.session.user.id, input.eventId);
const limit = await consumeRateLimit({ namespace: "banner-upload", identifier: ctx.session.user.id, limit: 20, windowMs: 600_000 });
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many banner uploads. Try again shortly." });
const id = randomUUID();
const originalKey = `events/${input.eventId}/banners/${id}/original`;
await getDb().insert(eventBanners).values({ id, eventId: input.eventId, originalKey, contentType: input.contentType, byteSize: input.byteSize });
return { bannerId: id, uploadUrl: await createPresignedPutUrl({ key: originalKey, contentType: input.contentType }) };
}),
complete: protectedProcedure.input(bannerInputSchema).mutation(async ({ ctx, input }) => {
await authorize(ctx.session.user.id, input.eventId);
const scope = and(eq(eventBanners.id, input.bannerId), eq(eventBanners.eventId, input.eventId));
const [banner] = await getDb().select().from(eventBanners).where(scope).limit(1);
if (!banner) throw new TRPCError({ code: "NOT_FOUND" });
if (banner.status !== "uploading") return { status: banner.status };
const head = await headObject(banner.originalKey);
const size = Number(head?.ContentLength ?? 0);
if (!head || size <= 0 || size > MAX_PHOTO_BYTES || head.ContentType !== banner.contentType) {
throw new TRPCError({ code: "BAD_REQUEST", message: "The banner upload is missing, too large, or has the wrong file type." });
}
await getDb().update(eventBanners).set({ status: "pending", byteSize: size, updatedAt: new Date() })
.where(and(scope, eq(eventBanners.status, "uploading")));
return { status: "pending" };
}),
status: protectedProcedure.input(bannerInputSchema).query(async ({ ctx, input }) => {
await authorize(ctx.session.user.id, input.eventId);
const [banner] = await getDb().select().from(eventBanners)
.where(and(eq(eventBanners.id, input.bannerId), eq(eventBanners.eventId, input.eventId))).limit(1);
if (!banner) throw new TRPCError({ code: "NOT_FOUND" });
return { status: banner.status, url: banner.status === "ready" && banner.displayKey ? await createPresignedGetUrl(banner.displayKey) : null };
}),
});
+61 -12
View File
@@ -1,13 +1,33 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import { customBannerUrl, eventBannerUrl } from "@/server/event-banner";
import { and, count, desc, eq, isNotNull, ne, sql } from "drizzle-orm";
import { galleryIsPublic } from "@/lib/publishing";
import { effectiveEvent, notesAreVisible } from "@/lib/event-lifecycle";
import { events, getDb, guests, photos, submissions } from "@album/database";
import { eventSlugSchema } from "@album/contracts";
import { createPresignedGetUrl } from "@album/storage";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const eventRouter = createTRPCRouter({
community: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
const [stored] = await getDb().select().from(events).where(eq(events.slug, input)).limit(1);
const event = stored ? effectiveEvent(stored) : null;
if (!event || event.status === "draft") throw new TRPCError({ code: "NOT_FOUND" });
const noteFilter = and(eq(guests.eventId, event.id), isNotNull(guests.note), ne(guests.note, ""));
const [notes, photoCount, submitterCount, noteCount] = await Promise.all([
!notesAreVisible(event.notesPolicy, event.notesVisibleAt) ? [] : getDb().select({ id: guests.id, displayName: guests.displayName, note: guests.note })
.from(guests).where(and(noteFilter, eq(guests.noteApproved, true))).orderBy(desc(guests.updatedAt)),
event.showPhotoStats ? getDb().select({ value: count() }).from(photos)
.where(and(eq(photos.eventId, event.id), ne(photos.processingStatus, "uploading"))) : [],
event.showSubmitterStats ? getDb().select({ value: count() }).from(guests)
.where(and(eq(guests.eventId, event.id), sql`((${guests.note} IS NOT NULL AND ${guests.note} <> '') OR EXISTS (SELECT 1 FROM submissions s JOIN photos p ON p.submission_id = s.id AND p.event_id = ${event.id} WHERE s.guest_id = ${guests.id} AND s.event_id = ${event.id} AND p.processing_status <> 'uploading'))`)) : [],
event.showNoteStats ? getDb().select({ value: count() }).from(guests).where(noteFilter) : [],
]);
return { notes, stats: { photos: photoCount[0]?.value ?? null, submitters: submitterCount[0]?.value ?? null, notes: noteCount[0]?.value ?? null } };
}),
listed: publicProcedure.query(async () => {
return getDb()
const listed = await getDb()
.select({
id: events.id,
slug: events.slug,
@@ -16,49 +36,78 @@ export const eventRouter = createTRPCRouter({
startsAt: events.startsAt,
endsAt: events.endsAt,
galleryReleasedAt: events.galleryReleasedAt,
galleryPolicy: events.galleryPolicy,
bannerPhotoId: events.bannerPhotoId,
customBannerId: events.customBannerId,
galleryVisibleAt: events.galleryVisibleAt,
})
.from(events)
.where(and(eq(events.listed, true), eq(events.status, "published")))
.where(and(eq(events.listed, true), sql`(${events.status} = 'published' OR (${events.status} = 'draft' AND ${events.publishAt} <= now()))`, sql`(${events.publishAt} IS NULL OR ${events.publishAt} <= now())`))
.orderBy(desc(events.startsAt), desc(events.createdAt));
return Promise.all(listed.map(async ({ bannerPhotoId, customBannerId, ...event }) => ({
...event,
bannerUrl: customBannerId
? await customBannerUrl(event.id, customBannerId)
: galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt) ? await eventBannerUrl(event.id, bannerPhotoId) : null,
})));
}),
bySlug: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
const [event] = await getDb()
const [stored] = await getDb()
.select({
id: events.id,
slug: events.slug,
title: events.title,
description: events.description,
location: events.location,
latitude: events.latitude,
longitude: events.longitude,
bannerPhotoId: events.bannerPhotoId,
customBannerId: events.customBannerId,
startsAt: events.startsAt,
endsAt: events.endsAt,
status: events.status,
listed: events.listed,
uploadEnabled: events.uploadEnabled,
publishAt: events.publishAt,
submissionsOpenAt: events.submissionsOpenAt,
submissionsCloseAt: events.submissionsCloseAt,
completedAt: events.completedAt,
galleryVisibleAt: events.galleryVisibleAt,
notesVisibleAt: events.notesVisibleAt,
notesPolicy: events.notesPolicy,
galleryPolicy: events.galleryPolicy,
showPhotoStats: events.showPhotoStats,
showSubmitterStats: events.showSubmitterStats,
showNoteStats: events.showNoteStats,
galleryReleasedAt: events.galleryReleasedAt,
})
.from(events)
.where(eq(events.slug, input))
.limit(1);
const event = stored ? effectiveEvent(stored) : null;
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
return event;
return {
...event,
bannerUrl: event.customBannerId
? await customBannerUrl(event.id, event.customBannerId)
: galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt) ? await eventBannerUrl(event.id, event.bannerPhotoId) : null,
};
}),
gallery: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
const [event] = await getDb()
.select({
id: events.id,
status: events.status,
galleryReleasedAt: events.galleryReleasedAt,
})
const [stored] = await getDb()
.select()
.from(events)
.where(eq(events.slug, input))
.limit(1);
const event = stored ? effectiveEvent(stored) : null;
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
if (!event.galleryReleasedAt) {
if (!galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt)) {
return [];
}
const rows = await getDb()
+15 -4
View File
@@ -1,17 +1,20 @@
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { events, getDb, guests, submissions } from "@album/database";
import { ensureGuestInputSchema, startSubmissionInputSchema } from "@album/contracts";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { guestCookieName, serializeCookie } from "@/server/cookies";
import { hashToken, newToken } from "@/server/tokens";
import { consumeRateLimit } from "@/server/rate-limit";
import { effectiveEvent } from "@/lib/event-lifecycle";
async function requirePublishedEvent(slug: string) {
const [event] = await getDb()
const [stored] = await getDb()
.select()
.from(events)
.where(eq(events.slug, slug))
.limit(1);
const event = stored ? effectiveEvent(stored) : null;
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
@@ -23,13 +26,19 @@ export const guestRouter = createTRPCRouter({
.input(ensureGuestInputSchema)
.mutation(async ({ ctx, input }) => {
const event = await requirePublishedEvent(input.eventSlug);
if (input.note && event.status !== "published") throw new TRPCError({ code: "FORBIDDEN", message: "Notes are closed for this event" });
if (input.note && event.submissionsOpenAt && event.submissionsOpenAt > new Date()) throw new TRPCError({ code: "FORBIDDEN", message: "Submissions have not opened yet" });
if (input.note) {
const limit = await consumeRateLimit({ namespace: `notes:${event.id}`, identifier: ctx.clientIdentifier, limit: 20, windowMs: 10 * 60 * 1000 });
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many notes. Try again shortly." });
}
const existingToken = ctx.guestTokenForEvent(event.id);
let guest = null;
if (existingToken) {
const [row] = await getDb()
.select()
.from(guests)
.where(eq(guests.tokenHash, hashToken(existingToken)))
.where(and(eq(guests.eventId, event.id), eq(guests.tokenHash, hashToken(existingToken))))
.limit(1);
if (row && row.eventId === event.id) guest = row;
}
@@ -43,9 +52,10 @@ export const guestRouter = createTRPCRouter({
notifyWhenReady: input.notifyWhenReady ?? guest.notifyWhenReady,
note:
input.note === undefined ? guest.note : input.note || null,
noteApproved: input.note === undefined || input.note === guest.note ? guest.noteApproved : event.notesPolicy === "automatic",
updatedAt: new Date(),
})
.where(eq(guests.id, guest.id))
.where(and(eq(guests.eventId, event.id), eq(guests.id, guest.id)))
.returning();
return {
guestId: updated!.id,
@@ -62,6 +72,7 @@ export const guestRouter = createTRPCRouter({
email: input.email ?? null,
notifyWhenReady: Boolean(input.notifyWhenReady && input.email),
note: input.note || null,
noteApproved: Boolean(input.note) && event.notesPolicy === "automatic",
tokenHash: hashToken(token),
})
.returning();
+194 -43
View File
@@ -1,7 +1,15 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import { customBannerUrl, eventBannerUrl } from "@/server/event-banner";
import { searchLocations } from "@/server/location-search";
import { notifyEventGuests } from "@/server/guest-notifications";
import { renderAlbumReadyEmail, emailBrowserPreview } from "@album/email";
import { emailDeliveryOutcome } from "@album/email/webhooks";
import { galleryIsPublic } from "@/lib/publishing";
import { effectiveEvent } from "@/lib/event-lifecycle";
import { and, desc, eq, sql, inArray, like } from "drizzle-orm";
import {
auditEvents,
emailDeliveries,
eventMemberships,
events,
getDb,
@@ -13,7 +21,11 @@ import {
} from "@album/database";
import {
createEventInputSchema,
checkEventSlugInputSchema,
generateEventSlugInputSchema,
locationSearchInputSchema,
moderatePhotoInputSchema,
moderateNoteInputSchema,
moderateSubmissionInputSchema,
setEventMemberInputSchema,
updateEventInputSchema,
@@ -23,7 +35,6 @@ import {
deletePrefix,
photoObjectPrefix,
} from "@album/storage";
import { sendAlbumReadyEmail } from "@album/email";
import { z } from "zod";
import {
createTRPCRouter,
@@ -68,6 +79,16 @@ async function signedPhotoUrls(photo: {
}
export const managerRouter = createTRPCRouter({
searchLocations: protectedProcedure.input(locationSearchInputSchema).query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, platformRole);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
try {
return await searchLocations(input.query);
} catch {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Location search is unavailable. Try again, or keep a text-only location." });
}
}),
events: protectedProcedure.query(async ({ ctx }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const memberships = await getDb()
@@ -81,15 +102,21 @@ export const managerRouter = createTRPCRouter({
.orderBy(desc(events.createdAt));
if (ctx.activeGroupId) {
return memberships
return Promise.all(memberships
.filter((row) => row.event.groupId === ctx.activeGroupId)
.map((row) => ({ ...row.event, role: row.role }));
.map(async (row) => ({ ...row.event, role: row.role,
bannerUrl: row.event.customBannerId ? await customBannerUrl(row.event.id, row.event.customBannerId) : await eventBannerUrl(row.event.id, row.event.bannerPhotoId),
})));
}
if (platformRole && hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.EVENTS_READ)) {
const all = await getDb().select().from(events).orderBy(desc(events.createdAt));
return all.map((event) => ({ ...event, role: "platform" as const }));
return Promise.all(all.map(async (event) => ({ ...event, role: "platform" as const,
bannerUrl: event.customBannerId ? await customBannerUrl(event.id, event.customBannerId) : await eventBannerUrl(event.id, event.bannerPhotoId),
})));
}
return memberships.map((row) => ({ ...row.event, role: row.role }));
return Promise.all(memberships.map(async (row) => ({ ...row.event, role: row.role,
bannerUrl: row.event.customBannerId ? await customBannerUrl(row.event.id, row.event.customBannerId) : await eventBannerUrl(row.event.id, row.event.bannerPhotoId),
})));
}),
event: protectedProcedure
@@ -103,6 +130,7 @@ export const managerRouter = createTRPCRouter({
);
return {
...event,
bannerUrl: event.customBannerId ? await customBannerUrl(event.id, event.customBannerId) : await eventBannerUrl(event.id, event.bannerPhotoId),
guestUrl: `${publicAppOrigin()}/e/${event.slug}`,
permissions: access.permissions,
role: access.role,
@@ -225,6 +253,19 @@ export const managerRouter = createTRPCRouter({
return event;
}),
checkSlug: protectedProcedure.input(checkEventSlugInputSchema).query(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
const [existing] = await getDb().select({ id: events.id }).from(events).where(eq(events.slug, input.slug)).limit(1);
return { available: !existing || existing.id === input.eventId };
}),
generateSlug: protectedProcedure.input(generateEventSlugInputSchema).query(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
return { slug: await uniqueEventSlug(slugify(input.title), input.eventId) };
}),
updateEvent: protectedProcedure
.input(updateEventInputSchema)
.mutation(async ({ ctx, input }) => {
@@ -235,9 +276,26 @@ export const managerRouter = createTRPCRouter({
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
const slug = input.slug
? await uniqueEventSlug(input.slug, event.id)
: event.slug;
if (input.bannerPhotoId) {
const bannerUrl = await eventBannerUrl(event.id, input.bannerPhotoId);
if (!bannerUrl) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a processed, public photo from this event for the banner." });
}
}
if (input.customBannerId && !(await customBannerUrl(event.id, input.customBannerId))) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a finished banner upload from this event." });
}
if (input.customBannerId && input.bannerPhotoId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose one banner source." });
}
const slug = input.slug ?? event.slug;
const schedule = { ...event, ...input };
if (schedule.startsAt && schedule.endsAt && schedule.endsAt <= schedule.startsAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Event end must be after its start." });
if (schedule.submissionsOpenAt && schedule.submissionsCloseAt && schedule.submissionsCloseAt <= schedule.submissionsOpenAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Submission closing must be after opening." });
if (slug !== event.slug) {
const [existing] = await getDb().select({ id: events.id }).from(events).where(eq(events.slug, slug)).limit(1);
if (existing) throw new TRPCError({ code: "CONFLICT", message: "That guest link is already taken. Choose another or generate one." });
}
const [updated] = await getDb()
.update(events)
.set({
@@ -245,15 +303,41 @@ export const managerRouter = createTRPCRouter({
slug,
description:
input.description === undefined ? event.description : input.description,
location: input.location === undefined ? event.location : input.location,
latitude: input.locationCoordinates === undefined
? (input.location !== undefined && input.location !== event.location ? null : event.latitude)
: input.locationCoordinates?.latitude ?? null,
longitude: input.locationCoordinates === undefined
? (input.location !== undefined && input.location !== event.location ? null : event.longitude)
: input.locationCoordinates?.longitude ?? null,
bannerPhotoId: input.customBannerId ? null : input.bannerPhotoId === undefined ? event.bannerPhotoId : input.bannerPhotoId,
customBannerId: input.customBannerId === undefined ? (input.bannerPhotoId ? null : event.customBannerId) : input.customBannerId,
startsAt: input.startsAt === undefined ? event.startsAt : input.startsAt,
endsAt: input.endsAt === undefined ? event.endsAt : input.endsAt,
status: input.status ?? event.status,
listed: input.listed ?? event.listed,
uploadEnabled: input.uploadEnabled ?? event.uploadEnabled,
publishAt: input.status ? null : input.publishAt === undefined ? event.publishAt : input.publishAt,
submissionsOpenAt: input.submissionsOpenAt === undefined ? event.submissionsOpenAt : input.submissionsOpenAt,
submissionsCloseAt: input.submissionsCloseAt === undefined ? (input.status === "published" && event.submissionsCloseAt && event.submissionsCloseAt <= new Date() ? null : event.submissionsCloseAt) : input.submissionsCloseAt,
galleryVisibleAt: input.galleryVisibleAt === undefined ? event.galleryVisibleAt : input.galleryVisibleAt,
notesVisibleAt: input.notesVisibleAt === undefined ? event.notesVisibleAt : input.notesVisibleAt,
completedAt: input.status === "published" ? null : event.completedAt,
notesPolicy: input.notesPolicy ?? event.notesPolicy,
galleryPolicy: input.galleryPolicy ?? event.galleryPolicy,
showPhotoStats: input.showPhotoStats ?? event.showPhotoStats,
showSubmitterStats: input.showSubmitterStats ?? event.showSubmitterStats,
showNoteStats: input.showNoteStats ?? event.showNoteStats,
updatedAt: new Date(),
})
.where(eq(events.id, event.id))
.returning();
.returning().catch((error: unknown) => {
const dbError = error as { code?: string; cause?: { code?: string } };
if (dbError.code === "23505" || dbError.cause?.code === "23505") {
throw new TRPCError({ code: "CONFLICT", message: "That guest link was just taken. Choose another or generate one." });
}
throw error;
});
await writeAudit({
groupId: event.groupId,
eventId: event.id,
@@ -261,6 +345,11 @@ export const managerRouter = createTRPCRouter({
action: "event.update",
subjectType: "event",
subjectId: event.id,
metadata: Object.fromEntries(Object.keys(input).filter((key) => key !== "eventId" && key in event && key in updated!).flatMap((key) => {
const before = JSON.stringify(event[key as keyof typeof event]);
const after = JSON.stringify(updated![key as keyof typeof event]);
return before === after ? [] : [[`${key}.before`, before ?? null], [`${key}.after`, after ?? null]];
})),
});
return updated!;
}),
@@ -275,6 +364,8 @@ export const managerRouter = createTRPCRouter({
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
if (event.galleryPolicy === "never") throw new TRPCError({ code: "BAD_REQUEST", message: "Enable gallery publishing before releasing it." });
if ((event.publishAt && event.publishAt > new Date()) || (event.galleryVisibleAt && event.galleryVisibleAt > new Date())) throw new TRPCError({ code: "BAD_REQUEST", message: "Clear the future visibility schedule before releasing now." });
const releasedAt = event.galleryReleasedAt ?? new Date();
await getDb()
.update(events)
@@ -284,31 +375,9 @@ export const managerRouter = createTRPCRouter({
updatedAt: new Date(),
})
.where(eq(events.id, event.id));
let notified = 0;
let queued = 0;
if (input.notifyGuests !== false) {
const waiting = await getDb()
.select()
.from(guests)
.where(
and(
eq(guests.eventId, event.id),
eq(guests.notifyWhenReady, true),
),
);
const galleryUrl = `${publicAppOrigin()}/e/${event.slug}`;
for (const guest of waiting) {
if (!guest.email || guest.notifiedAt) continue;
await sendAlbumReadyEmail({
to: guest.email,
eventTitle: event.title,
galleryUrl,
});
await getDb()
.update(guests)
.set({ notifiedAt: new Date(), updatedAt: new Date() })
.where(eq(guests.id, guest.id));
notified += 1;
}
queued = (await notifyEventGuests(event, ctx.session.user.id)).queued;
}
await writeAudit({
groupId: event.groupId,
@@ -317,9 +386,9 @@ export const managerRouter = createTRPCRouter({
action: "gallery.release",
subjectType: "event",
subjectId: event.id,
metadata: { notified },
metadata: { queued },
});
return { ok: true as const, notified };
return { ok: true as const, queued };
}),
photos: protectedProcedure
@@ -405,7 +474,7 @@ export const managerRouter = createTRPCRouter({
action: "photo.visibility",
subjectType: "photo",
subjectId: photo.id,
metadata: { visibility: input.visibility },
metadata: { "visibility.before": photo.visibility, "visibility.after": input.visibility },
});
return updated!;
}),
@@ -515,6 +584,16 @@ export const managerRouter = createTRPCRouter({
return { ok: true as const };
}),
moderateNote: protectedProcedure.input(moderateNoteInputSchema).mutation(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
const rows = await getDb().update(guests).set({ noteApproved: input.approved, updatedAt: new Date() })
.where(and(eq(guests.eventId, input.eventId), eq(guests.id, input.guestId))).returning({ id: guests.id });
if (!rows.length) throw new TRPCError({ code: "NOT_FOUND" });
await writeAudit({ eventId: input.eventId, actorUserId: ctx.session.user.id, action: "note.approval", subjectType: "guest", subjectId: input.guestId, metadata: { approved: input.approved } });
return { ok: true };
}),
notes: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
@@ -530,6 +609,7 @@ export const managerRouter = createTRPCRouter({
id: guests.id,
displayName: guests.displayName,
note: guests.note,
noteApproved: guests.noteApproved,
createdAt: guests.createdAt,
})
.from(guests)
@@ -537,6 +617,53 @@ export const managerRouter = createTRPCRouter({
.orderBy(desc(guests.createdAt));
}),
completeEvent: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
if (effectiveEvent(event).status === "draft") throw new TRPCError({ code: "BAD_REQUEST", message: "Publish the event before completing it." });
await getDb().update(events).set({ completedAt: new Date(), status: "closed", updatedAt: new Date() }).where(eq(events.id, event.id));
await writeAudit({ eventId: event.id, groupId: event.groupId, actorUserId: ctx.session.user.id, action: "event.complete", subjectType: "event", subjectId: event.id });
return { ok: true };
}),
notifyGuests: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
if (effectiveEvent(event).status !== "closed") throw new TRPCError({ code: "BAD_REQUEST", message: "Complete the event before sending completion emails." });
if ((event.publishAt && event.publishAt > new Date()) || !galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt)) throw new TRPCError({ code: "BAD_REQUEST", message: "Make the gallery public before notifying guests." });
return notifyEventGuests(event, ctx.session.user.id);
}),
emailPreview: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => {
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
const rendered = renderAlbumReadyEmail({ to: "preview@manyangles.test", eventTitle: event.title, galleryUrl: `${publicAppOrigin()}/e/${event.slug}` });
return { html: emailBrowserPreview(rendered.html), subject: rendered.subject };
}),
emailHistory: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
return getDb().select({ id: emailDeliveries.id, recipient: emailDeliveries.recipient, status: emailDeliveries.status, outcome: emailDeliveryOutcome, attempts: emailDeliveries.attempts, providerId: emailDeliveries.providerId, lastError: emailDeliveries.lastError, updatedAt: emailDeliveries.updatedAt, provider: emailDeliveries.provider, firstAttemptAt: emailDeliveries.firstAttemptAt })
.from(emailDeliveries).where(eq(emailDeliveries.eventId, input.eventId)).orderBy(desc(emailDeliveries.createdAt)).limit(100);
}),
retryEmail: protectedProcedure.input(z.object({ eventId: z.string().uuid(), deliveryId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
const rows = await getDb().update(emailDeliveries).set({ status: "pending", nextAttemptAt: new Date(), updatedAt: new Date() }).where(and(eq(emailDeliveries.eventId, input.eventId), eq(emailDeliveries.id, input.deliveryId), eq(emailDeliveries.status, "review"), eq(emailDeliveries.provider, "resend"), sql`${emailDeliveries.firstAttemptAt} > now() - interval '23 hours'`)).returning({ id: emailDeliveries.id });
if (!rows.length) throw new TRPCError({ code: "BAD_REQUEST", message: "This delivery cannot safely retry. Check the provider delivery record; its retry window may have expired." });
await writeAudit({ eventId: input.eventId, actorUserId: ctx.session.user.id, action: "guest.email.retry", subjectType: "email", subjectId: input.deliveryId });
return { ok: true };
}),
guests: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_READ);
return getDb().select({ id: guests.id, displayName: guests.displayName, email: guests.email, notifyWhenReady: guests.notifyWhenReady, notifiedAt: guests.notifiedAt, notificationClaimedAt: guests.notificationClaimedAt, createdAt: guests.createdAt })
.from(guests).where(eq(guests.eventId, input.eventId)).orderBy(desc(guests.createdAt));
}),
members: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
@@ -618,7 +745,7 @@ export const managerRouter = createTRPCRouter({
action: "event.member.set",
subjectType: "user",
subjectId: target.id,
metadata: { role: input.role },
metadata: { "role.before": existing?.role ?? null, "role.after": input.role },
});
return { ok: true as const };
}),
@@ -669,7 +796,7 @@ export const managerRouter = createTRPCRouter({
}),
audit: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.input(z.object({ eventId: z.string().uuid(), page: z.number().int().min(0).max(10000).default(0), category: z.enum(["all", "event", "photo", "note", "guest", "gallery", "submission"]).default("all") }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess(
@@ -678,9 +805,10 @@ export const managerRouter = createTRPCRouter({
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.AUDIT_READ);
return getDb()
const rows = await getDb()
.select({
id: auditEvents.id,
actorName: user.name,
action: auditEvents.action,
subjectType: auditEvents.subjectType,
subjectId: auditEvents.subjectId,
@@ -689,9 +817,32 @@ export const managerRouter = createTRPCRouter({
actorUserId: auditEvents.actorUserId,
})
.from(auditEvents)
.where(eq(auditEvents.eventId, input.eventId))
.orderBy(desc(auditEvents.createdAt))
.limit(100);
.leftJoin(user, eq(user.id, auditEvents.actorUserId))
.where(and(eq(auditEvents.eventId, input.eventId), input.category === "all" ? undefined : like(auditEvents.action, `${input.category}.%`)))
.orderBy(desc(auditEvents.createdAt), desc(auditEvents.id))
.limit(50).offset(input.page * 50);
const ids = (type: string) => rows.filter((row) => row.subjectType === type).map((row) => row.subjectId);
const [accounts, attendees, assets] = await Promise.all([
ids("user").length ? getDb().select({ id: user.id, name: user.name }).from(user).where(inArray(user.id, ids("user"))) : [],
ids("guest").length ? getDb().select({ id: guests.id, name: guests.displayName }).from(guests).where(and(eq(guests.eventId, input.eventId), inArray(guests.id, ids("guest")))) : [],
ids("photo").length ? getDb().select({ id: photos.id, displayKey: photos.displayKey, visibility: photos.visibility }).from(photos).where(and(eq(photos.eventId, input.eventId), inArray(photos.id, ids("photo")))) : [],
]);
return Promise.all(rows.map(async (row) => {
let subjectLabel = typeof row.metadata.subjectLabel === "string" ? row.metadata.subjectLabel : `${row.subjectType} ${row.subjectId}`;
let assetUrl: string | null = null;
if (row.subjectType === "user") {
const person = accounts.find((person) => person.id === row.subjectId);
if (person) subjectLabel = person.name;
} else if (row.subjectType === "guest") {
const person = attendees.find((person) => person.id === row.subjectId);
if (person) subjectLabel = person.name ?? "Anonymous guest";
} else if (row.subjectType === "photo") {
const photo = assets.find((photo) => photo.id === row.subjectId);
const allowed = access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ) ? PRIVATE_VISIBILITIES : MEMBER_VISIBILITIES;
if (access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_READ) && photo?.displayKey && allowed.includes(photo.visibility)) assetUrl = await createPresignedGetUrl(photo.displayKey);
}
return { ...row, subjectLabel, assetUrl };
}));
}),
deleteEvent: protectedProcedure
+17 -7
View File
@@ -15,16 +15,18 @@ import { createTRPCRouter, publicProcedure } from "../trpc";
import { consumeRateLimit } from "@/server/rate-limit";
import { hashToken } from "@/server/tokens";
import { guests } from "@album/database";
import { effectiveEvent } from "@/lib/event-lifecycle";
export const photosRouter = createTRPCRouter({
create: publicProcedure
.input(createPhotoInputSchema)
.mutation(async ({ ctx, input }) => {
const [event] = await getDb()
const [stored] = await getDb()
.select()
.from(events)
.where(eq(events.slug, input.eventSlug))
.limit(1);
const event = stored ? effectiveEvent(stored) : null;
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
@@ -37,7 +39,7 @@ export const photosRouter = createTRPCRouter({
const [submission] = await getDb()
.select()
.from(submissions)
.where(eq(submissions.id, input.submissionId))
.where(and(eq(submissions.id, input.submissionId), eq(submissions.eventId, event.id)))
.limit(1);
if (!submission || submission.eventId !== event.id) {
throw new TRPCError({
@@ -58,6 +60,7 @@ export const photosRouter = createTRPCRouter({
.where(
and(
eq(guests.id, submission.guestId),
eq(guests.eventId, event.id),
eq(guests.tokenHash, hashToken(token)),
),
)
@@ -86,7 +89,7 @@ export const photosRouter = createTRPCRouter({
eventId: event.id,
submissionId: submission.id,
processingStatus: "uploading",
visibility: "pending",
visibility: event.galleryPolicy === "automatic" ? "public" : "pending",
originalKey: "pending",
contentType: input.contentType,
byteSize: input.byteSize,
@@ -97,7 +100,7 @@ export const photosRouter = createTRPCRouter({
await getDb()
.update(photos)
.set({ originalKey: key, updatedAt: new Date() })
.where(eq(photos.id, photo.id));
.where(and(eq(photos.id, photo.id), eq(photos.eventId, event.id)));
const uploadUrl = await createPresignedPutUrl({
key,
contentType: input.contentType,
@@ -107,13 +110,18 @@ export const photosRouter = createTRPCRouter({
complete: publicProcedure
.input(completePhotoInputSchema)
.mutation(async ({ input }) => {
.mutation(async ({ ctx, input }) => {
const [photo] = await getDb()
.select()
.from(photos)
.where(eq(photos.id, input.photoId))
.limit(1);
if (!photo) throw new TRPCError({ code: "NOT_FOUND" });
const token = ctx.guestTokenForEvent(photo.eventId);
const [owner] = token ? await getDb().select({ id: guests.id }).from(guests)
.innerJoin(submissions, and(eq(submissions.guestId, guests.id), eq(submissions.eventId, photo.eventId)))
.where(and(eq(submissions.id, photo.submissionId), eq(guests.eventId, photo.eventId), eq(guests.tokenHash, hashToken(token)))).limit(1) : [];
if (!owner) throw new TRPCError({ code: "FORBIDDEN", message: "Guest session does not match this upload" });
if (photo.processingStatus !== "uploading") {
return {
photoId: photo.id,
@@ -135,14 +143,16 @@ export const photosRouter = createTRPCRouter({
});
}
await getDb().transaction(async (tx) => {
await tx
const changed = await tx
.update(photos)
.set({
processingStatus: "processing",
byteSize: size,
updatedAt: new Date(),
})
.where(eq(photos.id, photo.id));
.where(and(eq(photos.id, photo.id), eq(photos.eventId, photo.eventId), eq(photos.processingStatus, "uploading")))
.returning({ id: photos.id });
if (!changed.length) return;
await tx.insert(photoJobs).values({
photoId: photo.id,
kind: "transcode",
+13 -3
View File
@@ -1,4 +1,5 @@
import { auditEvents, type Database } from "@album/database";
import { auditEvents, user, guests, type Database } from "@album/database";
import { and, eq } from "drizzle-orm";
import { getDb } from "@album/database";
export async function writeAudit(
@@ -11,8 +12,17 @@ export async function writeAudit(
subjectId: string;
metadata?: Record<string, string | number | boolean | null>;
},
db: Database = getDb(),
db: Pick<Database, "insert" | "select"> = getDb(),
) {
const [actor] = input.actorUserId ? await db.select({ name: user.name }).from(user).where(eq(user.id, input.actorUserId)).limit(1) : [];
let subjectLabel = `${input.subjectType} ${input.subjectId}`;
if (input.subjectType === "user") {
const [subject] = await db.select({ name: user.name }).from(user).where(eq(user.id, input.subjectId)).limit(1);
if (subject) subjectLabel = subject.name;
} else if (input.subjectType === "guest" && input.eventId) {
const [subject] = await db.select({ name: guests.displayName }).from(guests).where(and(eq(guests.eventId, input.eventId), eq(guests.id, input.subjectId))).limit(1);
if (subject) subjectLabel = subject.name ?? "Anonymous guest";
}
await db.insert(auditEvents).values({
groupId: input.groupId ?? null,
eventId: input.eventId ?? null,
@@ -20,6 +30,6 @@ export async function writeAudit(
action: input.action,
subjectType: input.subjectType,
subjectId: input.subjectId,
metadata: input.metadata ?? {},
metadata: { ...input.metadata, subjectLabel, actorName: actor?.name ?? "System" },
});
}
+21
View File
@@ -0,0 +1,21 @@
import { and, eq } from "drizzle-orm";
import { eventBanners, getDb, photos } from "@album/database";
import { createPresignedGetUrl } from "@album/storage";
// Resolve each time so hiding or deleting a photo also removes its banner.
export async function eventBannerUrl(eventId: string, photoId: string | null) {
if (!photoId) return null;
const [photo] = await getDb().select({ displayKey: photos.displayKey })
.from(photos).where(and(
eq(photos.id, photoId), eq(photos.eventId, eventId),
eq(photos.visibility, "public"), eq(photos.processingStatus, "ready"),
)).limit(1);
return photo?.displayKey ? createPresignedGetUrl(photo.displayKey) : null;
}
export async function customBannerUrl(eventId: string, bannerId: string | null) {
if (!bannerId) return null;
const [banner] = await getDb().select({ displayKey: eventBanners.displayKey }).from(eventBanners)
.where(and(eq(eventBanners.id, bannerId), eq(eventBanners.eventId, eventId), eq(eventBanners.status, "ready"))).limit(1);
return banner?.displayKey ? createPresignedGetUrl(banner.displayKey) : null;
}
@@ -0,0 +1,34 @@
import { expect, test } from "bun:test";
import { and, eq } from "drizzle-orm";
import { events, getDb, groups, guests, groupMemberships, auditEvents, emailDeliveries } from "@album/database";
import { processEmailDelivery } from "@album/email/queue";
import { notifyEventGuests } from "./guest-notifications";
test.skipIf(process.env.NOTIFICATIONS_INTEGRATION !== "1")("Mailpit completion email is opt-in and deduplicated", async () => {
if (process.env.EMAIL_PROVIDER !== "mailpit" || process.env.NODE_ENV === "production" || !["localhost", "127.0.0.1"].includes(process.env.SMTP_HOST ?? "")) throw new Error("This test requires local, non-production Mailpit");
const db = getDb();
const [group] = await db.select({ id: groups.id }).from(groups).limit(1);
if (!group) throw new Error("Seed a development group first");
const [event] = await db.insert(events).values({ groupId: group.id, title: "Notification integration test", slug: `mail-test-${crypto.randomUUID()}`, status: "closed", galleryPolicy: "automatic" }).returning();
if (!event) throw new Error("Could not create fixture");
try {
const [member] = await db.select({ userId: groupMemberships.userId }).from(groupMemberships).where(eq(groupMemberships.groupId, group.id)).limit(1);
if (!member) throw new Error("Seed a group member first");
await db.insert(guests).values([
{ eventId: event.id, email: "publishing-test@manyangles.test", notifyWhenReady: true, tokenHash: crypto.randomUUID() },
{ eventId: event.id, email: "PUBLISHING-test@manyangles.test", notifyWhenReady: true, tokenHash: crypto.randomUUID() },
{ eventId: event.id, email: "not-subscribed@manyangles.test", notifyWhenReady: false, tokenHash: crypto.randomUUID() },
]);
const results = await Promise.all([notifyEventGuests(event, member.userId), notifyEventGuests(event, member.userId)]);
expect(results.reduce((sum, result) => sum + result.queued, 0)).toBe(1);
await processEmailDelivery();
const [delivery] = await db.select().from(emailDeliveries).where(eq(emailDeliveries.eventId, event.id));
expect(delivery?.status).toBe("sent");
expect(await notifyEventGuests(event, member.userId)).toEqual({ queued: 0 });
const [excluded] = await db.select({ notifiedAt: guests.notifiedAt }).from(guests).where(and(eq(guests.eventId, event.id), eq(guests.notifyWhenReady, false)));
expect(excluded?.notifiedAt).toBeNull();
} finally {
await db.delete(auditEvents).where(eq(auditEvents.eventId, event.id));
await db.delete(events).where(eq(events.id, event.id));
}
});
@@ -0,0 +1,25 @@
import { and, eq, sql } from "drizzle-orm";
import { emailDeliveries, getDb, guests } from "@album/database";
import { renderAlbumReadyEmail } from "@album/email";
import { writeAudit } from "./audit";
import { publicAppOrigin } from "./public-app-url";
export async function notifyEventGuests(event: { id: string; groupId: string; slug: string; title: string }, actorUserId: string) {
return getDb().transaction(async (tx) => {
const rows = await tx.select().from(guests).where(eq(guests.eventId, event.id));
const previous = new Set(rows.filter((g) => g.notifiedAt || g.notificationClaimedAt).flatMap((g) => g.email ? [g.email.toLowerCase()] : []));
const recipients = new Set(rows.filter((g) => g.notifyWhenReady && g.email && !previous.has(g.email.toLowerCase())).map((g) => g.email!.toLowerCase()));
let queued = 0;
for (const recipient of recipients) {
const inserted = await tx.insert(emailDeliveries).values({ eventId: event.id, recipient,
provider: process.env.EMAIL_PROVIDER ?? "resend",
payload: renderAlbumReadyEmail({ to: recipient, eventTitle: event.title, galleryUrl: `${publicAppOrigin()}/e/${event.slug}` }),
}).onConflictDoNothing().returning({ id: emailDeliveries.id });
if (!inserted.length) continue;
await tx.update(guests).set({ notificationClaimedAt: new Date() }).where(and(eq(guests.eventId, event.id), eq(guests.notifyWhenReady, true), sql`lower(${guests.email}) = ${recipient}`));
queued++;
}
await writeAudit({ eventId: event.id, groupId: event.groupId, actorUserId, action: "guest.email.queued", subjectType: "event", subjectId: event.id, metadata: { queued } }, tx);
return { queued };
});
}
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { parseLocationResults } from "./location-search";
import { updateEventInputSchema } from "@album/contracts";
import { openStreetMapEmbedUrl } from "@/lib/maps";
describe("event locations", () => {
test("maps GeoJSON longitude/latitude correctly and deduplicates matches", () => {
const feature = { geometry: { coordinates: [-72.7, 40.9] }, properties: { name: "Raceway", city: "Riverhead" } };
expect(parseLocationResults({ features: [feature, feature] })).toEqual([
{ address: "Raceway, Riverhead", latitude: 40.9, longitude: -72.7 },
]);
});
test("rejects impossible coordinates from the provider and event input", () => {
expect(parseLocationResults({ features: [{ geometry: { coordinates: [200, 95] }, properties: { name: "Bad" } }] })).toEqual([]);
expect(updateEventInputSchema.safeParse({ eventId: "7fa618fc-3fcb-4fd4-a0af-03f14124cccc", locationCoordinates: { latitude: 95, longitude: 0 } }).success).toBe(false);
});
test("keeps zero coordinates and uses latitude first in the OSM marker", () => {
const url = new URL(openStreetMapEmbedUrl(0, 12));
expect(url.searchParams.get("marker")).toBe("0,12");
expect(url.searchParams.get("bbox")).toBe("11.992,-0.008,12.008,0.008");
});
});
+43
View File
@@ -0,0 +1,43 @@
import { z } from "zod";
import { locationSuggestionSchema, type LocationSuggestion } from "@album/contracts";
const photonSchema = z.object({
features: z.array(z.object({
geometry: z.object({ coordinates: z.tuple([z.number(), z.number()]) }),
properties: z.record(z.unknown()),
})),
});
export function parseLocationResults(payload: unknown): LocationSuggestion[] {
const parsed = photonSchema.parse(payload);
const seen = new Set<string>();
return parsed.features.flatMap(({ geometry, properties }) => {
const text = (key: string) => typeof properties[key] === "string" ? properties[key] as string : "";
const street = [text("housenumber"), text("street")].filter(Boolean).join(" ");
const address = [...new Set([
text("name"), street, text("city") || text("district") || text("county"),
text("state"), text("postcode"), text("country"),
].filter(Boolean))].join(", ");
const result = locationSuggestionSchema.safeParse({
address, longitude: geometry.coordinates[0], latitude: geometry.coordinates[1],
});
const key = `${address}:${geometry.coordinates.join(",")}`;
if (!address || !result.success || seen.has(key)) return [];
seen.add(key);
return [result.data];
});
}
export async function searchLocations(query: string) {
const url = new URL("https://photon.komoot.io/api/");
url.searchParams.set("q", query);
url.searchParams.set("limit", "6");
url.searchParams.set("lang", "en");
const response = await fetch(url, {
headers: { Accept: "application/json", "User-Agent": "Manyangles event location search" },
next: { revalidate: 86_400 },
signal: AbortSignal.timeout(8000),
});
if (!response.ok) throw new Error("Location search unavailable");
return parseLocationResults(await response.json());
}
+3 -2
View File
@@ -30,7 +30,8 @@ export async function uniqueEventSlug(
excludeEventId?: string,
db: Database = getDb(),
) {
let candidate = desired.slice(0, 60) || "event";
const base = desired.slice(0, 64).replace(/-+$/, "") || "event";
let candidate = base;
let suffix = 2;
while (true) {
const [existing] = await db
@@ -39,7 +40,7 @@ export async function uniqueEventSlug(
.where(eq(events.slug, candidate))
.limit(1);
if (!existing || existing.id === excludeEventId) return candidate;
candidate = `${desired.slice(0, 50)}-${suffix}`;
candidate = `${base.slice(0, 64 - String(suffix).length - 1).replace(/-+$/, "")}-${suffix}`;
suffix += 1;
}
}
@@ -0,0 +1,110 @@
import { expect, test } from "bun:test";
import { and, eq } from "drizzle-orm";
import { events, getDb, groups, guests, photos, photoJobs } from "@album/database";
import { deletePrefix, getObjectBuffer, photoObjectPrefix, headObject } from "@album/storage";
import { photosRouter } from "./api/routers/photos";
import { eventRouter } from "./api/routers/event";
import { guestRouter } from "./api/routers/guest";
import type { TrpcContext } from "./api/trpc";
// Opt in against a migrated development database; creates and removes its own event.
test.skipIf(process.env.PUBLISHING_INTEGRATION !== "1")("standalone notes, approval reset, hidden stats, and gallery policies", async () => {
const db = getDb();
if (!["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
const [group] = await db.select({ id: groups.id }).from(groups).limit(1);
if (!group) throw new Error("Seed a development group first");
const [event] = await db.insert(events).values({ groupId: group.id, title: "Publishing test", slug: `publishing-test-${crypto.randomUUID()}`, status: "published", uploadEnabled: false }).returning();
if (!event) throw new Error("Could not create test event");
let token: string | null = null;
const ctx: TrpcContext = { session: null, requestOrigin: "http://localhost:3000", clientIdentifier: "publishing-test", cookies: new Map(), activeGroupId: null,
guestTokenForEvent: () => token, setCookies: [], appendSetCookie: (value) => { token = decodeURIComponent(value.split(";")[0]!.split("=").slice(1).join("=")); } };
const guest = guestRouter.createCaller(ctx);
const publicEvent = eventRouter.createCaller(ctx);
let uploadedPhotoId: string | null = null;
try {
const result = await guest.ensure({ eventSlug: event.slug, note: "Private note without a photo" });
expect((await publicEvent.community(event.slug))).toEqual({ notes: [], stats: { photos: null, submitters: null, notes: null } });
await db.update(events).set({ notesPolicy: "automatic", showPhotoStats: true, showSubmitterStats: true, showNoteStats: true }).where(eq(events.id, event.id));
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
await guest.ensure({ eventSlug: event.slug, note: "New automatic note" });
const automatic = await publicEvent.community(event.slug);
expect(automatic.notes).toHaveLength(1);
expect(automatic.stats).toEqual({ photos: 0, submitters: 1, notes: 1 });
expect(Object.keys(automatic.notes[0]!)).toEqual(["id", "displayName", "note"]);
await db.update(events).set({ notesPolicy: "approved" }).where(eq(events.id, event.id));
await guest.ensure({ eventSlug: event.slug, note: "Edited note requires approval" });
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
await db.update(guests).set({ noteApproved: true }).where(and(eq(guests.eventId, event.id), eq(guests.id, result.guestId)));
expect((await publicEvent.community(event.slug)).notes).toHaveLength(1);
const future = new Date(Date.now() + 3600000);
const past = new Date(Date.now() - 3600000);
await db.update(events).set({ notesVisibleAt: future, galleryVisibleAt: future, publishAt: future }).where(eq(events.id, event.id));
await expect(publicEvent.community(event.slug)).rejects.toThrow();
await db.update(events).set({ publishAt: past }).where(eq(events.id, event.id));
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
expect(await publicEvent.gallery(event.slug)).toEqual([]);
await db.update(events).set({ notesVisibleAt: past, galleryVisibleAt: past, submissionsOpenAt: future }).where(eq(events.id, event.id));
expect((await publicEvent.community(event.slug)).notes).toHaveLength(1);
await expect(guest.ensure({ eventSlug: event.slug, note: "Too early" })).rejects.toThrow();
await db.update(events).set({ submissionsOpenAt: past }).where(eq(events.id, event.id));
const emailOnly = guestRouter.createCaller({ ...ctx, guestTokenForEvent: () => null, appendSetCookie: () => {} });
const emailGuest = await emailOnly.ensure({ eventSlug: event.slug, email: "email-only@manyangles.test", notifyWhenReady: true });
const [saved] = await db.select({ note: guests.note, optIn: guests.notifyWhenReady }).from(guests).where(and(eq(guests.eventId, event.id), eq(guests.id, emailGuest.guestId)));
expect(saved).toEqual({ note: null, optIn: true });
await db.update(events).set({ galleryVisibleAt: null }).where(eq(events.id, event.id));
await db.update(events).set({ galleryPolicy: "automatic", uploadEnabled: true }).where(eq(events.id, event.id));
const submission = await guest.startSubmission({ eventSlug: event.slug });
const photoCaller = photosRouter.createCaller(ctx);
let original: Buffer | null = null;
if (process.env.REAL_UPLOAD_INTEGRATION === "1") {
const [demo] = await db.select({ id: events.id }).from(events).where(eq(events.slug, "demo"));
const [source] = await db.select({ key: photos.originalKey }).from(photos).where(and(eq(photos.eventId, demo!.id), eq(photos.processingStatus, "ready"))).limit(1);
original = await getObjectBuffer(source!.key);
}
const created = await photoCaller.create({ eventSlug: event.slug, submissionId: submission.submissionId, contentType: "image/jpeg", fileName: "test.jpg", byteSize: original?.length ?? 100 });
await expect(photosRouter.createCaller({ ...ctx, guestTokenForEvent: () => null }).complete({ photoId: created.photoId })).rejects.toThrow("Guest session");
expect(await publicEvent.gallery(event.slug)).toEqual([]);
if (original) {
uploadedPhotoId = created.photoId;
expect((await fetch(created.uploadUrl, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: new Uint8Array(original) })).ok).toBe(true);
await Promise.all([photoCaller.complete({ photoId: created.photoId }), photoCaller.complete({ photoId: created.photoId })]);
expect(await db.select({ id: photoJobs.id }).from(photoJobs).where(eq(photoJobs.photoId, created.photoId))).toHaveLength(1);
let ready = false;
for (let attempt = 0; attempt < 45; attempt++) {
const [photo] = await db.select().from(photos).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
if (photo?.processingStatus === "failed") throw new Error("Image processing failed");
if (photo?.processingStatus === "ready") {
expect(photo.displayKey).toBeTruthy();
expect(photo.thumbKey).toBeTruthy();
expect(Number((await headObject(photo.displayKey!))?.ContentLength)).toBeLessThan(original.length);
expect(Number((await headObject(photo.thumbKey!))?.ContentLength)).toBeLessThan(original.length);
ready = true;
break;
}
await Bun.sleep(500);
}
expect(ready).toBe(true);
} else {
// Fast policy-only test; opt in above for real storage and worker processing.
await db.update(photos).set({ processingStatus: "ready" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
}
expect(await publicEvent.gallery(event.slug)).toHaveLength(1);
await db.update(photos).set({ visibility: "pending" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
expect(await publicEvent.gallery(event.slug)).toEqual([]);
await db.update(photos).set({ visibility: "public" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
await db.update(events).set({ galleryPolicy: "approved" }).where(eq(events.id, event.id));
expect(await publicEvent.gallery(event.slug)).toEqual([]);
await db.update(events).set({ galleryReleasedAt: new Date() }).where(eq(events.id, event.id));
expect(await publicEvent.gallery(event.slug)).toHaveLength(1);
await db.update(events).set({ notesPolicy: "never", galleryPolicy: "never", galleryReleasedAt: new Date() }).where(eq(events.id, event.id));
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
expect(await publicEvent.gallery(event.slug)).toEqual([]);
await db.update(events).set({ status: "closed" }).where(eq(events.id, event.id));
await expect(guest.ensure({ eventSlug: event.slug, note: "Closed" })).rejects.toThrow("Notes are closed");
await db.update(events).set({ status: "draft", publishAt: null }).where(eq(events.id, event.id));
await expect(publicEvent.community(event.slug)).rejects.toThrow();
} finally {
if (uploadedPhotoId) await deletePrefix(photoObjectPrefix(event.id, uploadedPhotoId));
await db.delete(events).where(eq(events.id, event.id));
}
}, 30000);