diff --git a/apps/web/src/app/dashboard/events/[id]/event-people.tsx b/apps/web/src/app/dashboard/events/[id]/event-people.tsx
index af7d5ab..2ab6778 100644
--- a/apps/web/src/app/dashboard/events/[id]/event-people.tsx
+++ b/apps/web/src/app/dashboard/events/[id]/event-people.tsx
@@ -6,6 +6,7 @@ import { useState } from "react";
import { toast } from "sonner";
import type { EventRole } from "@album/contracts";
import { api } from "@/trpc/react";
+import { PendingInvites } from "@/components/pending-invites";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -56,7 +57,7 @@ export function EventPeople({
onError: (error) => toast.error(error.message),
});
const invite = api.group.inviteEmail.useMutation({
- onSuccess: () => toast.success("Invite emailed"),
+ onSuccess: async () => { toast.success("Invite emailed"); setEmail(""); await utils.group.pendingInvites.invalidate(); },
onError: (error) => toast.error(error.message),
});
const event = api.manager.event.useQuery({ eventId });
@@ -181,6 +182,7 @@ export function EventPeople({
) : null}
+ {canManage && event.data?.groupId ? : null}
);
diff --git a/apps/web/src/app/dashboard/events/[id]/sign/page.tsx b/apps/web/src/app/dashboard/events/[id]/sign/page.tsx
index 1ac53c1..f416d04 100644
--- a/apps/web/src/app/dashboard/events/[id]/sign/page.tsx
+++ b/apps/web/src/app/dashboard/events/[id]/sign/page.tsx
@@ -11,7 +11,7 @@ export default async function GuestSignPage({ params }: { params: Promise<{ id:
try { event = await caller.manager.event({ eventId: id }); } catch { notFound(); }
const current = effectiveEvent(event);
const saved = await caller.signs.get({ eventId: id });
- return ;
}
diff --git a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx
index 948a45c..97420e6 100644
--- a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx
+++ b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
-import { ArrowLeftIcon, DownloadIcon, PrinterIcon, QrCodeIcon, SaveIcon } from "lucide-react";
+import { ArrowLeftIcon, DownloadIcon, PrinterIcon, QrCodeIcon, SaveIcon, RotateCcwIcon } from "lucide-react";
import { savedSignSchema } from "@album/contracts";
import { api } from "@/trpc/react";
import { toast } from "sonner";
@@ -11,7 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { guestQrSvg, guestSignSvg, signPrintHtml, signPaper, SIGN_PAPERS, type SignDesign, type SignQr, type SignPaper } from "@/lib/guest-sign";
+import { guestQrSvg, guestSignSvg, signPrintHtml, signPrintMetrics, signPaper, SIGN_PAPERS, type SignDesign, type SignQr, type SignPaper } from "@/lib/guest-sign";
import { SignDesignControls } from "./design-controls";
function serializedDesign(design: SignDesign) {
@@ -31,13 +31,45 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
const [revision, setRevision] = useState(savedRevision);
const [snapshot, setSnapshot] = useState(() => serializedDesign(design));
const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+ const [fontAttempt, setFontAttempt] = useState(0);
+ const initialSnapshot = useRef(snapshot);
+ const utils = api.useUtils();
const prepare = api.signs.prepare.useMutation();
const saveMutation = api.signs.save.useMutation();
const dirty = serializedDesign(design) !== snapshot;
+ function applyDesign(content: string) {
+ const restored = savedSignSchema.parse(JSON.parse(content));
+ setDesign(d => ({ ...restored, fontData: d.fontData, interFontData: d.interFontData, geologicaFontData: d.geologicaFontData }));
+ }
+ function discardChanges() {
+ if (!window.confirm("Discard your unsaved sign changes?")) return;
+ applyDesign(snapshot);
+ setSnapshot(JSON.stringify(savedSignSchema.parse(JSON.parse(snapshot))));
+ setSaveError(null);
+ }
+ async function reloadSaved() {
+ if (dirty && !window.confirm("Replace your unsaved changes with the latest saved design? Download your sign first if you want to keep a copy.")) return;
+ setLoadingDesign(true);
+ try {
+ const latest = await utils.signs.get.fetch({ eventId });
+ let content = initialSnapshot.current;
+ if (latest) {
+ const response = await fetch(latest.url, { signal: AbortSignal.timeout(20000) });
+ if (!response.ok) throw new Error("Couldn't download the saved design. Try again.");
+ content = JSON.stringify(savedSignSchema.parse(await response.json()));
+ }
+ applyDesign(content); setSnapshot(content); setRevision(latest?.revision ?? null);
+ setLoadError(false); setSaveError(null);
+ toast.success(latest ? "Latest saved design loaded" : "No saved design yet");
+ } catch (error) {
+ setSaveError(error instanceof Error ? error.message : "Couldn't reload the saved design. Your changes are still here.");
+ } finally { setLoadingDesign(false); }
+ }
useEffect(() => {
if (!savedUrl) return;
let active = true;
- void fetch(savedUrl).then(async response => {
+ void fetch(savedUrl, { signal: AbortSignal.timeout(20000) }).then(async response => {
if (!response.ok) throw new Error("Could not load saved design");
const loaded = savedSignSchema.parse(await response.json()) as SignDesign;
if (active) {
@@ -65,19 +97,24 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
const body = new Blob([content], { type: "application/json" });
if (body.size > 20 * 1024 * 1024) { toast.error("Design is too large. Use a smaller photo or logo."); return; }
setSaving(true);
+ setSaveError(null);
try {
const upload = await prepare.mutateAsync({ eventId });
- const response = await fetch(upload.url, { method: "PUT", headers: { "Content-Type": "application/json" }, body });
+ const response = await fetch(upload.url, { method: "PUT", headers: { "Content-Type": "application/json" }, body, signal: AbortSignal.timeout(60000) });
if (!response.ok) throw new Error("Design upload failed. Please try again.");
const result = await saveMutation.mutateAsync({ eventId, uploadId: upload.uploadId, revision });
setRevision(result.revision); setSnapshot(content); toast.success("Design saved to this event");
- } catch (error) { toast.error(error instanceof Error ? error.message : "Could not save design"); }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Could not save design";
+ setSaveError(message); toast.error(message);
+ }
finally { setSaving(false); }
}
useEffect(() => {
let active = true;
+ setFontError(false);
void Promise.all(["funnel-display", "inter", "geologica"].map(async name => {
- const response = await fetch(`/fonts/${name}.woff2`);
+ const response = await fetch(`/fonts/${name}.woff2`, { signal: AbortSignal.timeout(20000) });
if (!response.ok) throw new Error("Font unavailable");
const bytes = new Uint8Array(await response.arrayBuffer());
let binary = "";
@@ -87,7 +124,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
if (active) setDesign(d => ({ ...d, fontData, interFontData, geologicaFontData }));
}).catch(() => { if (active) setFontError(true); });
return () => { active = false; };
- }, []);
+ }, [fontAttempt]);
const printFrame = useRef(null);
const [printing, setPrinting] = useState(false);
useEffect(() => () => { printFrame.current?.remove(); }, []);
@@ -95,6 +132,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
let paper = SIGN_PAPERS.letter as ReturnType;
try { paper = signPaper(design); } catch (error) { sizeError = error instanceof Error ? error.message : "Invalid paper size"; }
const svg = guestSignSvg(sizeError ? { ...design, paper: "letter" } : design, guestUrl, qr);
+ const printMetrics = sizeError ? null : signPrintMetrics(design);
const fontReady = Boolean(design.fontData && design.interFontData && design.geologicaFontData);
const valid = Boolean(!loadingDesign && !loadError && design.title.trim() && design.headline.trim() && design.message.trim() && !sizeError && fontReady && !assetBusy && (design.layout !== "photo" || design.backgroundImage));
const localUrl = ["localhost", "127.0.0.1", "[::1]"].includes(new URL(guestUrl).hostname);
@@ -145,8 +183,14 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
- Make it yours{loadingDesign ? "Loading saved design…" : loadError ? "Could not load your saved design. Reload this page before editing." : saving ? "Saving design…" : dirty ? "Unsaved changes" : revision ? "Saved to this event" : "Choose a preset to get started."} {!canSave ? "You can preview and download; an event manager must save changes." : "Your photo, logo, and settings are saved together."}
+ Make it yours{loadingDesign ? "Loading saved design…" : loadError ? "Could not load your saved design. Use Reload saved design to try again." : saving ? "Saving design…" : dirty ? "Unsaved changes" : revision ? "Saved to this event" : "Choose a preset to get started."} {!canSave ? "You can preview and download; an event manager must save changes." : "Your photo, logo, and settings are saved together."}
+ {dirty || revision || loadError || saveError ?