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