Initial commit of Vellum, an event photo product for guest uploads, host moderation, and original-quality galleries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-07 19:36:14 -04:00
co-authored by Cursor
commit 27e2f196eb
149 changed files with 13847 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
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 { 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");
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" />
</>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { createServerCaller } from "@/trpc/server";
import { getPlatformRole } from "@/server/roles";
import { auth } from "@/server/auth";
import { headers } from "next/headers";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { PlatformUsers } from "./platform-users";
import { PlatformCodes } from "./platform-codes";
export default async function AdminPage() {
const session = await auth.api.getSession({ headers: await headers() });
const role = session ? await getPlatformRole(session.user.id) : null;
const caller = await createServerCaller();
const [groups, events, audit] = await Promise.all([
caller.platform.groups(),
caller.platform.events(),
caller.platform.audit(),
]);
const canManageUsers = role === "super_admin" || role === "admin";
return (
<div className="flex flex-col gap-8">
<div>
<h1 className="text-4xl font-semibold tracking-tight">Platform</h1>
<p className="text-sm text-muted-foreground">
Full access for deployment operators.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Groups</CardTitle>
<CardDescription>{groups.length} groups</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">
{groups.map((group) => (
<div key={group.id} className="flex justify-between gap-3">
<span>{group.name}</span>
<span className="text-muted-foreground">
{group.quota.unlimited
? "unlimited"
: `${group.quota.used}/${group.quota.eventLimit ?? 0}`}
{group.quota.complimentary ? " · comp" : ""}
</span>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Events</CardTitle>
<CardDescription>{events.length} events</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">
{events.map((event) => (
<div key={event.id} className="flex justify-between gap-3">
<span>{event.title}</span>
<div className="flex gap-1">
<Badge variant="secondary">{event.status}</Badge>
{event.listed ? <Badge variant="outline">listed</Badge> : null}
</div>
</div>
))}
</CardContent>
</Card>
</div>
{canManageUsers ? <PlatformUsers /> : null}
{canManageUsers ? (
<PlatformCodes groups={groups.map((group) => ({ id: group.id, name: group.name }))} />
) : null}
<Card>
<CardHeader>
<CardTitle>Audit</CardTitle>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-2 text-sm">
{audit.slice(0, 40).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>
))}
</ul>
</CardContent>
</Card>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function PlatformCodes({
groups,
}: {
groups: { id: string; name: string }[];
}) {
const [code, setCode] = useState<string | null>(null);
const [groupId, setGroupId] = useState(groups[0]?.id ?? "");
const createCode = api.platform.createCode.useMutation({
onSuccess: (result) => {
setCode(result.code);
toast.success("Code created");
},
onError: (error) => toast.error(error.message),
});
const grant = api.platform.grantEntitlement.useMutation({
onSuccess: () => toast.success("Complimentary unlimited granted"),
onError: (error) => toast.error(error.message),
});
return (
<Card>
<CardHeader>
<CardTitle>Invites and entitlements</CardTitle>
<CardDescription>
One-time or reusable codes, and complimentary event access.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
createCode.mutate({
reusable: false,
maxUses: 1,
grantEventLimit: 1,
grantComplimentary: true,
groupRole: "owner",
eventRole: "owner",
})
}
>
One-time create code
</Button>
<Button
variant="outline"
onClick={() =>
createCode.mutate({
reusable: true,
maxUses: 100,
grantUnlimitedEvents: true,
grantComplimentary: true,
groupRole: "owner",
eventRole: "owner",
})
}
>
Reusable unlimited code
</Button>
</div>
{code ? (
<p className="text-sm">
Share once: <code>{code}</code>
</p>
) : 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"
value={groupId}
onChange={(event) => setGroupId(event.target.value)}
>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
<Button
variant="secondary"
disabled={!groupId}
onClick={() =>
grant.mutate({
groupId,
eventLimit: null,
complimentary: true,
})
}
>
Make selected group unlimited
</Button>
</div>
) : null}
</CardContent>
</Card>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import type { PlatformRole } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const roles: Array<PlatformRole | "none"> = [
"none",
"viewer",
"moderator",
"admin",
"super_admin",
];
export function PlatformUsers() {
const [query, setQuery] = useState("");
const users = api.platform.users.useQuery({ query: query || undefined });
const setRole = api.platform.setPlatformRole.useMutation({
onSuccess: async () => {
toast.success("Role updated");
await users.refetch();
},
onError: (error) => toast.error(error.message),
});
return (
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription>Grant platform roles.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Input
placeholder="Search name or email"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<ul className="flex flex-col gap-2">
{(users.data ?? []).map((row) => (
<li key={row.id} className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-medium">{row.name}</p>
<p className="text-xs text-muted-foreground">{row.email}</p>
</div>
<div className="flex items-center gap-2">
{row.platformRole ? (
<Badge variant="secondary">{row.platformRole}</Badge>
) : null}
<select
className="rounded-md border bg-background px-2 py-1.5 text-sm"
value={row.platformRole ?? "none"}
onChange={(event) => {
const value = event.target.value as PlatformRole | "none";
setRole.mutate({
userId: row.id,
role: value === "none" ? null : value,
});
}}
>
{roles.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import type { EventCreatePolicy } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function DeploymentSettingsForm({
openSignup,
eventCreatePolicy,
defaultEventLimit,
}: {
openSignup: boolean;
eventCreatePolicy: EventCreatePolicy;
defaultEventLimit: number;
}) {
const [open, setOpen] = useState(openSignup);
const [policy, setPolicy] = useState(eventCreatePolicy);
const [limit, setLimit] = useState(String(defaultEventLimit));
const update = api.platform.updateSettings.useMutation({
onSuccess: () => toast.success("Settings saved"),
onError: (error) => toast.error(error.message),
});
return (
<Card>
<CardHeader>
<CardTitle>Access</CardTitle>
<CardDescription>
Open signup is for accounts. Event creation can still require an invite
or an administrator.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="flex flex-col gap-5"
onSubmit={(event) => {
event.preventDefault();
update.mutate({
openSignup: open,
eventCreatePolicy: policy,
defaultEventLimit: Number(limit),
});
}}
>
<FieldGroup>
<Field orientation="horizontal">
<FieldLabel htmlFor="signup">Open signups</FieldLabel>
<Switch id="signup" checked={open} onCheckedChange={setOpen} />
</Field>
<Field>
<FieldLabel htmlFor="policy">Event creation</FieldLabel>
<select
id="policy"
className="rounded-md border bg-background px-2 py-2"
value={policy}
onChange={(event) =>
setPolicy(event.target.value as EventCreatePolicy)
}
>
<option value="open">Open (quota still applies)</option>
<option value="invite">Invite code required</option>
<option value="admin_only">Administrators only</option>
</select>
</Field>
<Field>
<FieldLabel htmlFor="limit">Default event limit for new groups</FieldLabel>
<Input
id="limit"
type="number"
min={0}
value={limit}
onChange={(event) => setLimit(event.target.value)}
/>
</Field>
</FieldGroup>
<Button type="submit" disabled={update.isPending}>
Save
</Button>
</form>
</CardContent>
</Card>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/server/auth";
import { getPlatformRole } from "@/server/roles";
import { hasPlatformPermission } from "@/server/roles";
import { PLATFORM_PERMISSIONS } from "@/server/permissions";
import { createServerCaller } from "@/trpc/server";
import { DeploymentSettingsForm } from "./deployment-settings-form";
export default async function AdminSettingsPage() {
const session = await auth.api.getSession({ headers: await headers() });
const role = session ? await getPlatformRole(session.user.id) : null;
if (!hasPlatformPermission(role, PLATFORM_PERMISSIONS.SETTINGS_MANAGE)) {
redirect("/admin");
}
const caller = await createServerCaller();
const settings = await caller.platform.settings();
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-4xl font-semibold tracking-tight">Deployment settings</h1>
<p className="text-sm text-muted-foreground">
Signup policy and event creation rules for this install.
</p>
</div>
<DeploymentSettingsForm
openSignup={settings.openSignup}
eventCreatePolicy={settings.eventCreatePolicy}
defaultEventLimit={settings.defaultEventLimit}
/>
</div>
);
}
@@ -0,0 +1,4 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/server/auth";
export const { GET, POST } = toNextJsHandler(auth);
+34
View File
@@ -0,0 +1,34 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
import { publicAppOrigin } from "@/server/public-app-url";
async function handler(request: Request) {
let context: Awaited<ReturnType<typeof createTRPCContext>> | undefined;
const response = await fetchRequestHandler({
endpoint: "/api/trpc",
req: request,
router: appRouter,
createContext: async () => {
context = await createTRPCContext({
headers: request.headers,
requestOrigin: publicAppOrigin(request.url),
});
return context;
},
});
if (context?.setCookies.length) {
const headers = new Headers(response.headers);
for (const cookie of context.setCookies) {
headers.append("Set-Cookie", cookie);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
return response;
}
export { handler as GET, handler as POST };
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

@@ -0,0 +1,106 @@
"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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
export function CreateEventDialog() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [inviteCode, setInviteCode] = useState("");
const createEvent = api.manager.createEvent.useMutation({
onSuccess: (event) => {
toast.success("Event created");
setOpen(false);
setTitle("");
setDescription("");
setInviteCode("");
router.push(`/dashboard/events/${event.id}`);
router.refresh();
},
onError: (error) => {
toast.error(error.message);
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="tap-target w-full sm:w-auto">New event</Button>
</DialogTrigger>
<DialogContent>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
createEvent.mutate({
title,
description: description.trim() || undefined,
inviteCode: inviteCode.trim() || undefined,
});
}}
>
<DialogHeader>
<DialogTitle>Create event</DialogTitle>
<DialogDescription>
Guests will use a shareable link after you publish it.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="event-title">Title</FieldLabel>
<Input
id="event-title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Maya and Jonah"
/>
</Field>
<Field>
<FieldLabel htmlFor="event-description">Description</FieldLabel>
<Textarea
id="event-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Optional note for guests"
/>
</Field>
<Field>
<FieldLabel htmlFor="invite-code">Invite code (optional)</FieldLabel>
<Input
id="invite-code"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
placeholder="VELLUM-…"
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button type="submit" disabled={createEvent.isPending || !title.trim()}>
{createEvent.isPending ? <Spinner data-icon="inline-start" /> : null}
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,36 @@
"use client";
import { api } from "@/trpc/react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function EventAudit({ eventId }: { eventId: string }) {
const audit = api.manager.audit.useQuery({ eventId });
return (
<Card>
<CardHeader>
<CardTitle>Audit log</CardTitle>
<CardDescription>Recent changes for this event.</CardDescription>
</CardHeader>
<CardContent>
<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>
))}
</ul>
</CardContent>
</Card>
);
}
@@ -0,0 +1,55 @@
"use client";
import { api } from "@/trpc/react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
export function EventNotes({ eventId }: { eventId: string }) {
const notes = api.manager.notes.useQuery({ eventId });
const withNotes = (notes.data ?? []).filter((guest) => guest.note);
return (
<Card>
<CardHeader>
<CardTitle>Notes</CardTitle>
<CardDescription>
Messages guests left for the event people.
</CardDescription>
</CardHeader>
<CardContent>
{withNotes.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>No notes yet</EmptyTitle>
<EmptyDescription>
Guests can leave a note when they upload.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<ul className="flex flex-col gap-4">
{withNotes.map((guest) => (
<li key={guest.id} className="rounded-lg border p-4">
<p className="text-sm font-medium">
{guest.displayName ?? "Anonymous"}
</p>
<p className="mt-2 whitespace-pre-wrap text-sm">{guest.note}</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import type { EventRole } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const roles: EventRole[] = ["owner", "manager", "moderator", "viewer"];
export function EventPeople({
eventId,
canManage,
canGrantOwner,
}: {
eventId: string;
canManage: boolean;
canGrantOwner: boolean;
}) {
const utils = api.useUtils();
const members = api.manager.members.useQuery({ eventId });
const [email, setEmail] = useState("");
const [role, setRole] = useState<EventRole>("manager");
const setMember = api.manager.setMember.useMutation({
onSuccess: async () => {
toast.success("Member updated");
setEmail("");
await utils.manager.members.invalidate({ eventId });
},
onError: (error) => toast.error(error.message),
});
const invite = api.group.inviteEmail.useMutation({
onSuccess: () => toast.success("Invite emailed"),
onError: (error) => toast.error(error.message),
});
const event = api.manager.event.useQuery({ eventId });
const remove = api.manager.removeMember.useMutation({
onSuccess: async () => {
await utils.manager.members.invalidate({ eventId });
},
onError: (error) => toast.error(error.message),
});
return (
<Card>
<CardHeader>
<CardTitle>People</CardTitle>
<CardDescription>
The couple can both be owners. Managers run the day. Moderators review photos.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ul className="flex flex-col gap-2">
{(members.data ?? []).map((member) => (
<li key={member.id} className="flex items-center justify-between gap-3">
<div>
<p className="font-medium">{member.name}</p>
<p className="text-xs text-muted-foreground">{member.email}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{member.role}</Badge>
{canManage ? (
<Button
size="sm"
variant="ghost"
onClick={() =>
remove.mutate({ eventId, userId: member.userId })
}
>
Remove
</Button>
) : null}
</div>
</li>
))}
</ul>
{canManage ? (
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(formEvent) => {
formEvent.preventDefault();
if (role === "owner" && !canGrantOwner) {
toast.error("Only an owner can add another owner");
return;
}
setMember.mutate({ eventId, email, role });
}}
>
<Input
type="email"
required
placeholder="email@example.com"
value={email}
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)}
>
{roles
.filter((value) => value !== "owner" || canGrantOwner)
.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<Button type="submit" disabled={setMember.isPending}>
Add existing user
</Button>
<Button
type="button"
variant="outline"
disabled={invite.isPending || !event.data?.groupId}
onClick={() => {
if (!event.data?.groupId) return;
invite.mutate({
email,
groupId: event.data.groupId,
eventId,
eventRole: role,
groupRole: "member",
});
}}
>
Email invite
</Button>
</form>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,197 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function EventSettingsForm({
eventId,
title,
slug,
description,
status,
listed,
uploadEnabled,
galleryReleased,
}: {
eventId: string;
title: string;
slug: string;
description: string | null;
status: "draft" | "published" | "closed";
listed: boolean;
uploadEnabled: boolean;
galleryReleased: boolean;
}) {
const [formTitle, setFormTitle] = useState(title);
const [formSlug, setFormSlug] = useState(slug);
const [formDescription, setFormDescription] = useState(description ?? "");
const [formUploadEnabled, setFormUploadEnabled] = useState(uploadEnabled);
const [formListed, setFormListed] = useState(listed);
const utils = api.useUtils();
const updateEvent = api.manager.updateEvent.useMutation({
onSuccess: async () => {
toast.success("Event saved");
await utils.manager.event.invalidate({ eventId });
},
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.`
: "Gallery released",
);
await utils.manager.event.invalidate({ eventId });
},
onError: (error) => toast.error(error.message),
});
function save(next?: {
status?: "draft" | "published" | "closed";
uploadEnabled?: boolean;
listed?: boolean;
}) {
updateEvent.mutate({
eventId,
title: formTitle,
slug: formSlug,
description: formDescription.trim() || null,
uploadEnabled: next?.uploadEnabled ?? formUploadEnabled,
listed: next?.listed ?? formListed,
status: next?.status,
});
}
return (
<Card>
<CardHeader>
<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.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="flex flex-col gap-5"
onSubmit={(event) => {
event.preventDefault();
save();
}}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="title">Title</FieldLabel>
<Input
id="title"
value={formTitle}
onChange={(event) => setFormTitle(event.target.value)}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="slug">Guest link slug</FieldLabel>
<Input
id="slug"
value={formSlug}
onChange={(event) => setFormSlug(event.target.value)}
required
/>
<FieldDescription>Guests visit /e/{formSlug || "slug"}</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="description">Description</FieldLabel>
<Textarea
id="description"
value={formDescription}
onChange={(event) => setFormDescription(event.target.value)}
/>
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor="uploads">Accept uploads</FieldLabel>
<Switch
id="uploads"
checked={formUploadEnabled}
onCheckedChange={(checked) => {
setFormUploadEnabled(checked);
save({ uploadEnabled: checked });
}}
/>
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor="listed">Show on homepage</FieldLabel>
<Switch
id="listed"
checked={formListed}
onCheckedChange={(checked) => {
setFormListed(checked);
save({ listed: checked });
}}
/>
</Field>
</FieldGroup>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={updateEvent.isPending}>
{updateEvent.isPending ? <Spinner data-icon="inline-start" /> : null}
Save
</Button>
{status !== "published" ? (
<Button
type="button"
variant="outline"
disabled={updateEvent.isPending}
onClick={() => save({ status: "published" })}
>
Publish link
</Button>
) : (
<Button
type="button"
variant="outline"
disabled={updateEvent.isPending}
onClick={() => save({ status: "closed" })}
>
Close uploads
</Button>
)}
{status === "closed" ? (
<Button
type="button"
variant="ghost"
disabled={updateEvent.isPending}
onClick={() => save({ status: "published" })}
>
Reopen
</Button>
) : null}
<Button
type="button"
variant="secondary"
disabled={release.isPending}
onClick={() =>
release.mutate({ eventId, notifyGuests: true })
}
>
{galleryReleased ? "Notify guests again" : "Release gallery"}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,279 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import {
CheckIcon,
CopyIcon,
DownloadIcon,
EyeOffIcon,
LockIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { api } from "@/trpc/react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const processingOrder = {
pending: 0,
processing: 1,
uploading: 2,
ready: 3,
failed: 4,
} as const;
export function ModerationGrid({
eventId,
canModerate,
canDelete,
canPrivate,
}: {
eventId: string;
canModerate: boolean;
canDelete: boolean;
canPrivate: boolean;
}) {
const utils = api.useUtils();
const photos = api.manager.photos.useQuery({ eventId });
const moderate = api.manager.moderatePhoto.useMutation({
onSuccess: async () => {
await utils.manager.photos.invalidate({ eventId });
},
onError: (error) => toast.error(error.message),
});
const moderateSubmission = api.manager.moderateSubmission.useMutation({
onSuccess: async () => {
toast.success("Submission updated");
await utils.manager.photos.invalidate({ eventId });
},
onError: (error) => toast.error(error.message),
});
const remove = api.manager.deletePhoto.useMutation({
onSuccess: async () => {
toast.success("Photo deleted");
setPendingDelete(null);
await utils.manager.photos.invalidate({ eventId });
},
onError: (error) => toast.error(error.message),
});
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
if (photos.isLoading) {
return (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="aspect-square w-full rounded-xl" />
))}
</div>
);
}
const rows = [...(photos.data ?? [])].sort((left, right) => {
if (left.visibility === "pending" && right.visibility !== "pending") return -1;
if (right.visibility === "pending" && left.visibility !== "pending") return 1;
return (
processingOrder[left.processingStatus] - processingOrder[right.processingStatus]
);
});
if (rows.length === 0) {
return (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>No uploads yet</EmptyTitle>
<EmptyDescription>
Share the guest link. Incoming photos stay here until you choose visibility.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
return (
<>
<div className="grid grid-cols-2 gap-3 md: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">
{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"
/>
) : (
<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>
</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
</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>
) : 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>
</div>
</article>
))}
</div>
<Dialog
open={Boolean(pendingDelete)}
onOpenChange={(open) => {
if (!open) setPendingDelete(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this photo?</DialogTitle>
<DialogDescription>
The original and generated variants will be removed from storage.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!pendingDelete || remove.isPending}
onClick={() => pendingDelete && remove.mutate({ photoId: pendingDelete })}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export function CopyGuestLink({ url }: { url: string }) {
return (
<Button
type="button"
variant="outline"
className="tap-target w-full sm:w-auto"
onClick={async () => {
try {
await navigator.clipboard.writeText(url);
toast.success("Guest link copied");
} catch {
toast.error("Could not copy the link");
}
}}
>
<CopyIcon data-icon="inline-start" />
Copy guest link
</Button>
);
}
@@ -0,0 +1,122 @@
import { notFound } from "next/navigation";
import { createServerCaller } from "@/trpc/server";
import { Badge } from "@/components/ui/badge";
import { EventWorkspace, type EventWorkspaceTab } from "@/components/event-workspace";
import { EventSettingsForm } from "./event-settings-form";
import { CopyGuestLink, ModerationGrid } from "./moderation-grid";
import { EventPeople } from "./event-people";
import { EventNotes } from "./event-notes";
import { EventAudit } from "./event-audit";
export default async function EventDashboardPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const caller = await createServerCaller();
let event;
try {
event = await caller.manager.event({ eventId: id });
} catch {
notFound();
}
const canSettings = event.permissions.includes("settings.manage");
const canPeople = event.permissions.includes("people.read");
const canNotes = event.permissions.includes("notes.read");
const canAudit = event.permissions.includes("audit.read");
const canModerate = event.permissions.includes("photos.moderate");
const tabs: EventWorkspaceTab[] = [
{
id: "photos",
label: "Photos",
content: (
<ModerationGrid
eventId={event.id}
canModerate={canModerate}
canDelete={event.permissions.includes("photos.delete")}
canPrivate={event.permissions.includes("photos.private.read")}
/>
),
},
...(canSettings
? [
{
id: "settings",
label: "Settings",
content: (
<EventSettingsForm
eventId={event.id}
title={event.title}
slug={event.slug}
description={event.description}
status={event.status}
listed={event.listed}
uploadEnabled={event.uploadEnabled}
galleryReleased={Boolean(event.galleryReleasedAt)}
/>
),
},
]
: []),
...(canPeople
? [
{
id: "people",
label: "People",
content: (
<EventPeople
eventId={event.id}
canManage={event.permissions.includes("people.manage")}
canGrantOwner={event.permissions.includes("people.grant_owner")}
/>
),
},
]
: []),
...(canNotes
? [
{
id: "notes",
label: "Notes",
content: <EventNotes eventId={event.id} />,
},
]
: []),
...(canAudit
? [
{
id: "activity",
label: "Activity",
content: <EventAudit eventId={event.id} />,
},
]
: []),
];
return (
<EventWorkspace
heading={
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<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>
{event.listed ? <Badge variant="outline">Listed</Badge> : null}
{event.galleryReleasedAt ? (
<Badge variant="outline">Gallery live</Badge>
) : (
<Badge variant="outline">Gallery held</Badge>
)}
</div>
<p className="truncate text-sm text-muted-foreground">{event.guestUrl}</p>
</div>
<CopyGuestLink url={event.guestUrl} />
</div>
}
tabs={tabs}
/>
);
}
+59
View File
@@ -0,0 +1,59 @@
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";
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");
}
const caller = await createServerCaller();
const viewer = await caller.viewer.me();
const platformRole = await getPlatformRole(session.user.id);
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)} />
</>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import { createServerCaller } from "@/trpc/server";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
import { CreateEventDialog } from "./create-event-dialog";
export default async function DashboardPage() {
const caller = await createServerCaller();
const events = await caller.manager.events();
const viewer = await caller.viewer.me();
const activeGroup = viewer.groups.find((group) => group.id === viewer.activeGroupId)
?? viewer.groups[0];
let quota = null;
if (activeGroup) {
const group = await caller.group.get({ groupId: activeGroup.id });
quota = group.quota;
}
return (
<div className="reveal flex flex-col gap-6 sm:gap-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">Events</h1>
<p className="text-sm text-muted-foreground">
{quota?.unlimited
? "This group can create unlimited events."
: quota
? `${quota.used} of ${quota.eventLimit ?? 0} events used.`
: "Create a group by adding your first event."}
</p>
</div>
<div className="w-full sm:w-auto">
<CreateEventDialog />
</div>
</div>
{events.length === 0 ? (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>No events yet</EmptyTitle>
<EmptyDescription>
Start with a title. You can publish and share the guest link next.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<CreateEventDialog />
</EmptyContent>
</Empty>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{events.map((event) => (
<Link
key={event.id}
href={`/dashboard/events/${event.id}`}
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">
<CardHeader>
<CardTitle className="truncate text-2xl font-semibold tracking-tight">
{event.title}
</CardTitle>
<CardDescription className="truncate">/{event.slug}</CardDescription>
</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>
{event.listed ? <Badge variant="outline">Listed</Badge> : null}
</div>
<span className="text-sm text-primary">Open</span>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,156 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import type { GroupRole } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function GroupPeople({
groupId,
canManage,
}: {
groupId: string;
canManage: boolean;
}) {
const utils = api.useUtils();
const members = api.group.members.useQuery({ groupId });
const [email, setEmail] = useState("");
const [role, setRole] = useState<GroupRole>("member");
const [code, setCode] = useState<string | null>(null);
const setMember = api.group.setMember.useMutation({
onSuccess: async () => {
toast.success("Member updated");
setEmail("");
await utils.group.members.invalidate({ groupId });
},
onError: (error) => toast.error(error.message),
});
const invite = api.group.inviteEmail.useMutation({
onSuccess: () => toast.success("Invite emailed"),
onError: (error) => toast.error(error.message),
});
const createCode = api.group.createCode.useMutation({
onSuccess: (result) => {
setCode(result.code);
toast.success("Invite code created");
},
onError: (error) => toast.error(error.message),
});
const remove = api.group.removeMember.useMutation({
onSuccess: async () => {
await utils.group.members.invalidate({ groupId });
},
onError: (error) => toast.error(error.message),
});
return (
<Card>
<CardHeader>
<CardTitle>Group members</CardTitle>
<CardDescription>
People who can be added to events in this group.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ul className="flex flex-col gap-2">
{(members.data ?? []).map((member) => (
<li key={member.id} className="flex items-center justify-between gap-3">
<div>
<p className="font-medium">{member.name}</p>
<p className="text-xs text-muted-foreground">{member.email}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{member.role}</Badge>
{canManage ? (
<Button
size="sm"
variant="ghost"
onClick={() =>
remove.mutate({ groupId, userId: member.userId })
}
>
Remove
</Button>
) : null}
</div>
</li>
))}
</ul>
{canManage ? (
<>
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(formEvent) => {
formEvent.preventDefault();
setMember.mutate({ groupId, email, role });
}}
>
<Input
type="email"
required
placeholder="email@example.com"
value={email}
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>
<Button type="submit" disabled={setMember.isPending}>
Add existing user
</Button>
<Button
type="button"
variant="outline"
disabled={invite.isPending}
onClick={() =>
invite.mutate({
email,
groupId,
groupRole: role,
})
}
>
Email invite
</Button>
</form>
<Button
type="button"
variant="secondary"
onClick={() =>
createCode.mutate({
groupId,
reusable: true,
maxUses: 25,
groupRole: "member",
})
}
>
Create reusable code
</Button>
{code ? (
<p className="text-sm">
Share this code once: <code>{code}</code>
</p>
) : null}
</>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,39 @@
import { createServerCaller } from "@/trpc/server";
import { GroupPeople } from "./group-people";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
export default async function DashboardPeoplePage() {
const caller = await createServerCaller();
const viewer = await caller.viewer.me();
const groupId = viewer.activeGroupId ?? viewer.groups[0]?.id;
if (!groupId) {
return (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>No group yet</EmptyTitle>
<EmptyDescription>Create an event to start a group.</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
const group = await caller.group.get({ groupId });
return (
<div className="reveal flex flex-col gap-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">People</h1>
<p className="text-sm text-muted-foreground">
Shared members of {group.name}. Event access is still assigned per event.
</p>
</div>
<GroupPeople
groupId={group.id}
canManage={group.permissions.includes("group.people.manage")}
/>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { api } from "@/trpc/react";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function GuestGallery({ slug }: { slug: string }) {
const gallery = api.event.gallery.useQuery(slug);
const [active, setActive] = useState<string | null>(null);
if (gallery.isLoading) {
return (
<div className="columns-2 gap-3 sm:columns-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="mb-3 h-40 w-full break-inside-avoid" />
))}
</div>
);
}
const photos = gallery.data ?? [];
const selected = photos.find((photo) => photo.id === active);
if (photos.length === 0) {
return (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>No photos in the gallery yet</EmptyTitle>
<EmptyDescription>
Photos will appear here after the event people release the gallery.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
return (
<>
<div className="columns-2 gap-2 sm:columns-3 sm:gap-3">
{photos.map((photo, index) =>
photo.thumbUrl || photo.displayUrl ? (
<button
key={photo.id}
type="button"
className="photo-rise mb-2 block w-full break-inside-avoid overflow-hidden rounded-xl focus-visible:ring-3 focus-visible:ring-ring/50 sm:mb-3"
style={{ animationDelay: `${Math.min(index, 12) * 40}ms` }}
onClick={() => setActive(photo.id)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.thumbUrl ?? photo.displayUrl ?? ""}
alt={
photo.contributorName
? `Photo from ${photo.contributorName}`
: "Event photo"
}
width={photo.width ?? 800}
height={photo.height ?? 1000}
loading="lazy"
className="w-full transition-transform duration-300 ease-out hover:scale-[1.03] motion-reduce:transition-none motion-reduce:hover:scale-100"
/>
</button>
) : null,
)}
</div>
<Dialog open={Boolean(selected)} onOpenChange={(open) => !open && setActive(null)}>
<DialogContent className="overflow-hidden border-none bg-background p-3 sm:max-w-3xl sm:p-4">
<DialogHeader className="px-1">
<DialogTitle>
{selected?.contributorName ?? "Shared by a guest"}
</DialogTitle>
<DialogDescription className="sr-only">
Full-size event photo
</DialogDescription>
</DialogHeader>
{selected?.displayUrl || selected?.thumbUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={selected.displayUrl ?? selected.thumbUrl ?? ""}
alt=""
width={selected.width ?? 1600}
height={selected.height ?? 1200}
className="max-h-[75dvh] w-full rounded-lg object-contain"
/>
) : null}
</DialogContent>
</Dialog>
</>
);
}
+298
View File
@@ -0,0 +1,298 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { ChevronDownIcon, UploadIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { MAX_PHOTO_BYTES } from "@album/contracts";
import { api } from "@/trpc/react";
import { imageContentType, isAllowedPhoto, putWithProgress } from "@/lib/upload";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner";
type QueueItem = {
id: string;
name: string;
progress: number;
status: "queued" | "uploading" | "done" | "error";
error?: string;
};
function guestKey(slug: string, field: string) {
return `album:guest:${slug}:${field}`;
}
export function GuestUpload({
slug,
uploadEnabled,
}: {
slug: string;
uploadEnabled: boolean;
}) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [note, setNote] = useState("");
const [notify, setNotify] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(false);
const [dragging, setDragging] = useState(false);
const [queue, setQueue] = useState<QueueItem[]>([]);
const ensureGuest = api.guest.ensure.useMutation();
const startSubmission = api.guest.startSubmission.useMutation();
const createPhoto = api.photos.create.useMutation();
const completePhoto = api.photos.complete.useMutation();
const utils = api.useUtils();
useEffect(() => {
const storedName = localStorage.getItem(guestKey(slug, "name")) ?? "";
const storedEmail = localStorage.getItem(guestKey(slug, "email")) ?? "";
const storedNote = localStorage.getItem(guestKey(slug, "note")) ?? "";
setName(storedName);
setEmail(storedEmail);
setNote(storedNote);
if (storedName || storedEmail || storedNote) setDetailsOpen(true);
}, [slug]);
const busy = useMemo(
() => queue.some((item) => item.status === "queued" || item.status === "uploading"),
[queue],
);
async function uploadFiles(files: File[]) {
const accepted = files.filter(isAllowedPhoto);
if (accepted.length !== files.length) {
toast.error("Some files were skipped. Use JPEG, PNG, WebP, or HEIC under 25 MB.");
}
const items: QueueItem[] = accepted.map((file) => ({
id: crypto.randomUUID(),
name: file.name,
progress: 0,
status: "queued",
}));
setQueue((current) => [...items, ...current]);
const trimmedName = name.trim();
const trimmedEmail = email.trim();
const trimmedNote = note.trim();
localStorage.setItem(guestKey(slug, "name"), trimmedName);
localStorage.setItem(guestKey(slug, "email"), trimmedEmail);
localStorage.setItem(guestKey(slug, "note"), trimmedNote);
try {
await ensureGuest.mutateAsync({
eventSlug: slug,
displayName: trimmedName || undefined,
email: trimmedEmail || undefined,
notifyWhenReady: Boolean(trimmedEmail) && notify,
note: trimmedNote || undefined,
});
const submission = await startSubmission.mutateAsync({ eventSlug: slug });
for (const [index, file] of accepted.entries()) {
const item = items[index];
if (!item) continue;
const contentType = imageContentType(file);
if (!contentType) continue;
setQueue((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, status: "uploading" } : entry,
),
);
try {
const created = await createPhoto.mutateAsync({
eventSlug: slug,
submissionId: submission.submissionId,
contentType,
fileName: file.name,
byteSize: file.size,
});
await putWithProgress(created.uploadUrl, file, contentType, (progress) => {
setQueue((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, progress } : entry,
),
);
});
await completePhoto.mutateAsync({ photoId: created.photoId });
setQueue((current) =>
current.map((entry) =>
entry.id === item.id
? { ...entry, status: "done", progress: 100 }
: entry,
),
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Upload failed";
setQueue((current) =>
current.map((entry) =>
entry.id === item.id
? { ...entry, status: "error", error: message }
: entry,
),
);
}
}
await utils.event.gallery.invalidate(slug);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not start upload");
setQueue((current) =>
current.map((entry) =>
items.some((item) => item.id === entry.id) && entry.status === "queued"
? { ...entry, status: "error", error: "Could not start upload" }
: entry,
),
);
}
}
if (!uploadEnabled) {
return (
<Alert>
<AlertDescription>Uploads are closed for this event.</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-4">
<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",
)}
onDragEnter={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
setDragging(false);
}
}}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) void uploadFiles(files);
}}
>
<span className="flex size-14 items-center justify-center rounded-full bg-accent text-accent-foreground transition-transform duration-300 ease-out group-hover:scale-105">
<UploadIcon aria-hidden="true" />
</span>
<div className="flex flex-col gap-1">
<span className="text-base font-medium">Add photos from this phone</span>
<span className="text-sm text-foreground/70">
JPEG, PNG, WebP, or HEIC. Up to {Math.round(MAX_PHOTO_BYTES / (1024 * 1024))}&nbsp;MB each. Originals stay full quality.
</span>
</div>
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
multiple
className="sr-only"
disabled={busy}
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (files.length) void uploadFiles(files);
}}
/>
<Button type="button" disabled={busy} asChild className="tap-target">
<span>
{busy ? <Spinner data-icon="inline-start" /> : null}
{busy ? "Uploading…" : "Choose photos"}
</span>
</Button>
</label>
{queue.length > 0 ? (
<ul className="flex flex-col gap-3" aria-live="polite">
{queue.map((item) => (
<li key={item.id} className="flex flex-col gap-1">
<div className="flex justify-between gap-3 text-sm">
<span className="truncate">{item.name}</span>
<span className="text-muted-foreground">{item.status}</span>
</div>
<Progress value={item.progress} />
{item.error ? (
<p className="text-sm text-destructive">{item.error}</p>
) : null}
</li>
))}
</ul>
) : null}
<details
className="rounded-2xl border bg-card/60 px-4 py-1"
open={detailsOpen}
onToggle={(event) => setDetailsOpen(event.currentTarget.open)}
>
<summary className="tap-target flex cursor-pointer list-none items-center justify-between gap-3 py-2 font-medium [&::-webkit-details-marker]:hidden">
<span>Add a name or note</span>
<ChevronDownIcon
aria-hidden="true"
className={cn("size-4 shrink-0 transition-transform duration-200", detailsOpen && "rotate-180")}
/>
</summary>
<div className="pb-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="contributor-name">Your name (optional)</FieldLabel>
<Input
id="contributor-name"
name="contributor-name"
autoComplete="name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Maya…"
maxLength={80}
className="tap-target"
/>
</Field>
<Field>
<FieldLabel htmlFor="contributor-email">Email (optional)</FieldLabel>
<Input
id="contributor-email"
name="contributor-email"
type="email"
autoComplete="email"
inputMode="email"
spellCheck={false}
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@example.com"
className="tap-target"
/>
<FieldDescription>
Stay anonymous if you skip this. Remembered on this device for this event.
</FieldDescription>
</Field>
{email.trim() ? (
<Field orientation="horizontal">
<FieldLabel htmlFor="notify">Email me when the gallery is ready</FieldLabel>
<Switch id="notify" checked={notify} onCheckedChange={setNotify} />
</Field>
) : null}
<Field>
<FieldLabel htmlFor="note">A note for the event people (optional)</FieldLabel>
<Textarea
id="note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Congratulations — enjoy the day."
maxLength={2000}
/>
</Field>
</FieldGroup>
</div>
</details>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { notFound } from "next/navigation";
import { createServerCaller } from "@/trpc/server";
import { formatEventDate } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { GuestGallery } from "./guest-gallery";
import { GuestUpload } from "./guest-upload";
export default async function EventPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const caller = await createServerCaller();
let event;
try {
event = await caller.event.bySlug(slug);
} catch {
notFound();
}
const when = formatEventDate(event.startsAt);
const galleryLive = Boolean(event.galleryReleasedAt);
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">
<p className="text-xs font-medium tracking-[0.22em] text-primary uppercase">
Guest gallery
</p>
<h1 className="font-display text-4xl leading-tight sm:text-5xl">
{event.title}
</h1>
{when ? <p className="text-muted-foreground">{when}</p> : null}
{event.description ? (
<p className="max-w-2xl text-muted-foreground">{event.description}</p>
) : null}
</header>
<section id="upload" className="reveal-2 scroll-mt-24 flex flex-col gap-4">
<h2 className="sr-only">Add a photo</h2>
<GuestUpload
slug={event.slug}
uploadEnabled={event.uploadEnabled && event.status === "published"}
/>
</section>
<Separator />
<section className="reveal-3 flex flex-col gap-4">
<h2 className="text-2xl font-semibold tracking-tight">Gallery</h2>
{galleryLive ? (
<GuestGallery slug={event.slug} />
) : (
<p className="text-sm text-muted-foreground">
Photos will appear here when the event people release the gallery.
</p>
)}
</section>
</main>
);
}
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { MailIcon } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
export function ForgotPasswordForm() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const result = await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
setLoading(false);
if (result.error) {
setError("We could not start password recovery. Please try again.");
return;
}
setSent(true);
}
if (sent) {
return (
<div className="flex flex-col gap-5">
<Alert>
<AlertTitle>Check your email</AlertTitle>
<AlertDescription>
If an account exists for {email}, we sent a password-reset link.
</AlertDescription>
</Alert>
<Button asChild variant="outline" className="w-full">
<Link href="/sign-in">Return to sign in</Link>
</Button>
</div>
);
}
return (
<form className="flex flex-col gap-5" onSubmit={submit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="recovery-email">Email address</FieldLabel>
<Input
id="recovery-email"
type="email"
autoComplete="email"
autoFocus
required
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<Button type="submit" size="lg" className="w-full" disabled={loading}>
{loading ? (
<Spinner data-icon="inline-start" />
) : (
<MailIcon data-icon="inline-start" />
)}
Send reset link
</Button>
</form>
);
}
+26
View File
@@ -0,0 +1,26 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ForgotPasswordForm } from "./forgot-password-form";
export default function ForgotPasswordPage() {
return (
<main className="page-pad mx-auto flex min-h-[calc(100dvh-3.5rem)] w-full max-w-md items-center py-10 sm:min-h-[calc(100dvh-4rem)]">
<Card className="reveal w-full">
<CardHeader>
<CardTitle className="text-2xl font-semibold tracking-tight">Reset password</CardTitle>
<CardDescription>
We will email a one-hour reset link if the account exists.
</CardDescription>
</CardHeader>
<CardContent>
<ForgotPasswordForm />
</CardContent>
</Card>
</main>
);
}
+220
View File
@@ -0,0 +1,220 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-source-sans), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--font-source-sans), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-cormorant), ui-serif, Georgia, serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
color-scheme: light;
--background: oklch(0.965 0.016 82);
--foreground: oklch(0.26 0.03 48);
--card: oklch(0.99 0.008 85);
--card-foreground: oklch(0.26 0.03 48);
--popover: oklch(0.995 0.006 85);
--popover-foreground: oklch(0.26 0.03 48);
--primary: oklch(0.46 0.09 38);
--primary-foreground: oklch(0.99 0.01 85);
--secondary: oklch(0.94 0.02 80);
--secondary-foreground: oklch(0.3 0.04 48);
--muted: oklch(0.94 0.018 82);
--muted-foreground: oklch(0.48 0.03 55);
--accent: oklch(0.93 0.03 70);
--accent-foreground: oklch(0.3 0.04 48);
--destructive: oklch(0.55 0.18 28);
--border: oklch(0.88 0.025 78);
--input: oklch(0.86 0.025 78);
--ring: oklch(0.46 0.09 38);
--chart-1: oklch(0.72 0.08 55);
--chart-2: oklch(0.55 0.08 40);
--chart-3: oklch(0.45 0.06 70);
--chart-4: oklch(0.38 0.04 50);
--chart-5: oklch(0.3 0.03 45);
--radius: 0.85rem;
--sidebar: oklch(0.97 0.012 82);
--sidebar-foreground: oklch(0.26 0.03 48);
--sidebar-primary: oklch(0.46 0.09 38);
--sidebar-primary-foreground: oklch(0.99 0.01 85);
--sidebar-accent: oklch(0.93 0.03 70);
--sidebar-accent-foreground: oklch(0.3 0.04 48);
--sidebar-border: oklch(0.88 0.025 78);
--sidebar-ring: oklch(0.46 0.09 38);
}
.dark {
color-scheme: dark;
--background: oklch(0.2 0.02 50);
--foreground: oklch(0.96 0.015 85);
--card: oklch(0.25 0.022 52);
--card-foreground: oklch(0.96 0.015 85);
--popover: oklch(0.25 0.022 52);
--popover-foreground: oklch(0.96 0.015 85);
--primary: oklch(0.78 0.07 65);
--primary-foreground: oklch(0.22 0.03 50);
--secondary: oklch(0.3 0.025 55);
--secondary-foreground: oklch(0.96 0.015 85);
--muted: oklch(0.3 0.022 52);
--muted-foreground: oklch(0.76 0.03 75);
--accent: oklch(0.32 0.03 58);
--accent-foreground: oklch(0.96 0.015 85);
--destructive: oklch(0.7 0.14 25);
--border: oklch(0.96 0.02 85 / 12%);
--input: oklch(0.96 0.02 85 / 14%);
--ring: oklch(0.78 0.07 65);
--chart-1: oklch(0.78 0.07 65);
--chart-2: oklch(0.65 0.06 45);
--chart-3: oklch(0.55 0.05 75);
--chart-4: oklch(0.45 0.04 55);
--chart-5: oklch(0.38 0.03 50);
--sidebar: oklch(0.23 0.02 50);
--sidebar-foreground: oklch(0.96 0.015 85);
--sidebar-primary: oklch(0.78 0.07 65);
--sidebar-primary-foreground: oklch(0.22 0.03 50);
--sidebar-accent: oklch(0.3 0.025 55);
--sidebar-accent-foreground: oklch(0.96 0.015 85);
--sidebar-border: oklch(0.96 0.02 85 / 12%);
--sidebar-ring: oklch(0.78 0.07 65);
}
@keyframes reveal-up {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-rise {
from {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@layer utilities {
.page-pad {
padding-inline: max(1.25rem, env(safe-area-inset-left))
max(1.25rem, env(safe-area-inset-right));
}
.reveal {
animation: reveal-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.reveal-2 {
animation: reveal-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) 90ms both;
}
.reveal-3 {
animation: reveal-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) 180ms both;
}
.photo-rise {
animation: fade-rise 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.tap-target {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal,
.reveal-2,
.reveal-3,
.photo-rise {
animation: none;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
@apply font-sans bg-background;
background-color: var(--background);
-webkit-tap-highlight-color: color-mix(in oklab, var(--primary) 12%, transparent);
}
body {
@apply bg-background font-sans text-foreground antialiased;
background-image:
radial-gradient(1200px 520px at 8% -12%, oklch(0.92 0.045 70 / 0.55), transparent 60%),
radial-gradient(900px 420px at 110% 8%, oklch(0.93 0.035 40 / 0.4), transparent 55%);
background-attachment: fixed;
touch-action: manipulation;
}
.dark body {
background-image:
radial-gradient(1000px 480px at 0% -10%, oklch(0.32 0.04 55 / 0.55), transparent 60%),
radial-gradient(800px 380px at 100% 0%, oklch(0.3 0.04 70 / 0.35), transparent 55%);
}
.font-display {
font-family: var(--font-cormorant), ui-serif, Georgia, serif;
text-wrap: balance;
}
h1,
h2,
h3 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#f4eee4"/>
<path
fill="#6b4032"
fill-rule="evenodd"
d="M8.5 5h15A5.5 5.5 0 0 1 29 10.5v13A5.5 5.5 0 0 1 23.5 29h-15A5.5 5.5 0 0 1 3 23.5v-13A5.5 5.5 0 0 1 8.5 5Zm10.2 0 10.3 10.3V10.5A5.5 5.5 0 0 0 23.5 5h-4.8Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

@@ -0,0 +1,66 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { auth } from "@/server/auth";
import { createServerCaller } from "@/trpc/server";
import { RedeemInviteButton } from "./redeem-button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export default async function InvitationPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const caller = await createServerCaller();
let preview;
try {
preview = await caller.invites.preview({ token });
} catch {
return (
<main className="page-pad mx-auto max-w-md py-16">
<Card>
<CardHeader>
<CardTitle>Invite not found</CardTitle>
<CardDescription>This link may have expired.</CardDescription>
</CardHeader>
</Card>
</main>
);
}
if (!session) {
redirect(`/sign-up?invite=${encodeURIComponent(token)}`);
}
return (
<main className="page-pad mx-auto max-w-md py-16">
<Card>
<CardHeader>
<CardTitle className="text-2xl font-semibold tracking-tight">Join this event</CardTitle>
<CardDescription>
{preview.eventRole
? `You'll join as event ${preview.eventRole}.`
: preview.groupRole
? `You'll join the group as ${preview.groupRole}.`
: "This invite grants access."}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<RedeemInviteButton token={token} />
<Button asChild variant="ghost">
<Link href="/dashboard">Skip</Link>
</Button>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
export function RedeemInviteButton({ token }: { token: string }) {
const router = useRouter();
const redeem = api.invites.redeem.useMutation({
onSuccess: (result) => {
toast.success("Invite accepted");
router.push(
result.eventId
? `/dashboard/events/${result.eventId}`
: "/dashboard",
);
router.refresh();
},
onError: (error) => toast.error(error.message),
});
return (
<Button
onClick={() => redeem.mutate({ token })}
disabled={redeem.isPending}
>
{redeem.isPending ? <Spinner data-icon="inline-start" /> : null}
Accept invite
</Button>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata, Viewport } from "next";
import { Cormorant_Garamond, Source_Sans_3 } from "next/font/google";
import { TRPCReactProvider } from "@/trpc/react";
import { ThemeProvider } from "@/components/theme-provider";
import { SiteHeader } from "@/components/site-header";
import { Toaster } from "@/components/ui/sonner";
import { BRAND_NAME, BRAND_TITLE } from "@/lib/brand";
import "./globals.css";
const sourceSans = Source_Sans_3({
variable: "--font-source-sans",
subsets: ["latin"],
display: "swap",
});
const cormorant = Cormorant_Garamond({
variable: "--font-cormorant",
subsets: ["latin"],
display: "swap",
weight: ["500", "600", "700"],
});
export const metadata: Metadata = {
applicationName: BRAND_NAME,
title: {
default: BRAND_TITLE,
template: `%s · ${BRAND_NAME}`,
},
description:
"Guests upload photos. Event people choose what appears in the gallery.",
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f4eee4" },
{ media: "(prefers-color-scheme: dark)", color: "#2a231c" },
],
viewportFit: "cover",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html
lang="en"
className={`${sourceSans.variable} ${cormorant.variable} font-sans`}
suppressHydrationWarning
>
<body>
<ThemeProvider>
<TRPCReactProvider>
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:top-3 focus:left-3 focus:z-50 focus:rounded-lg focus:bg-card focus:px-3 focus:py-2 focus:text-sm"
>
Skip to content
</a>
<SiteHeader />
<div id="main">{children}</div>
<Toaster position="top-center" />
</TRPCReactProvider>
</ThemeProvider>
</body>
</html>
);
}
+16
View File
@@ -0,0 +1,16 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<main className="page-pad mx-auto flex min-h-[calc(100dvh-3.5rem)] w-full max-w-lg flex-col items-center justify-center gap-4 py-10 text-center sm:min-h-[calc(100dvh-4rem)]">
<h1 className="text-3xl font-semibold tracking-tight">Not found</h1>
<p className="text-muted-foreground">
That page or event is not available.
</p>
<Button asChild>
<Link href="/">Back home</Link>
</Button>
</main>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { ImageResponse } from "next/og";
import { BRAND_NAME, BRAND_TAGLINE } from "@/lib/brand";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function OpenGraphImage() {
return new ImageResponse(
(
<div
style={{
background: "#f4eee4",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: 88,
}}
>
<svg
width="112"
height="112"
viewBox="0 0 32 32"
fill="none"
>
<path
fill="#6b4032"
fillRule="evenodd"
d="M8.5 5h15A5.5 5.5 0 0 1 29 10.5v13A5.5 5.5 0 0 1 23.5 29h-15A5.5 5.5 0 0 1 3 23.5v-13A5.5 5.5 0 0 1 8.5 5Zm10.2 0 10.3 10.3V10.5A5.5 5.5 0 0 0 23.5 5h-4.8Z"
/>
</svg>
<div
style={{
marginTop: 36,
fontSize: 92,
lineHeight: 1,
color: "#3a2a22",
fontFamily: "Georgia, serif",
letterSpacing: -1,
}}
>
{BRAND_NAME}
</div>
<div
style={{
marginTop: 18,
fontSize: 28,
color: "#8b5a4a",
letterSpacing: 6,
textTransform: "uppercase",
}}
>
{BRAND_TAGLINE}
</div>
</div>
),
size,
);
}
+84
View File
@@ -0,0 +1,84 @@
import Link from "next/link";
import { CameraIcon } from "lucide-react";
import { createServerCaller } from "@/trpc/server";
import { Button } from "@/components/ui/button";
import { BrandMark } from "@/components/brand-mark";
import { formatEventDate } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default async function HomePage() {
const caller = await createServerCaller();
const listed = await caller.event.listed();
const viewer = await caller.viewer.me();
return (
<main className="page-pad mx-auto flex min-h-[calc(100dvh-3.5rem)] w-full max-w-5xl flex-col gap-12 py-10 sm:min-h-[calc(100dvh-4rem)] sm:py-16">
<div className="reveal flex max-w-2xl flex-col gap-5">
<p className="text-xs font-medium tracking-[0.22em] text-primary uppercase sm:text-sm">
Photos from the day
</p>
<h1 className="font-display text-4xl leading-[1.1] sm:text-6xl">
Keep the originals. Share the day.
</h1>
<p className="max-w-xl text-base text-muted-foreground sm:text-lg">
Guests upload from a link. You choose what the public sees. Full-quality
files stay yours.
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
{viewer.openSignup ? (
<Button asChild size="lg" className="tap-target w-full sm:w-auto">
<Link href="/sign-up">
<CameraIcon data-icon="inline-start" aria-hidden="true" />
Host an event
</Link>
</Button>
) : (
<Button asChild size="lg" className="tap-target w-full sm:w-auto">
<Link href="/sign-up">Have an invite?</Link>
</Button>
)}
<Button asChild variant="outline" size="lg" className="tap-target w-full sm:w-auto">
<Link href="/sign-in">Sign in</Link>
</Button>
</div>
</div>
{listed.length > 0 ? (
<section className="reveal-2 flex flex-col gap-4">
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Open galleries</h2>
<div className="grid gap-3 sm:grid-cols-2">
{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">
<CardHeader>
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-accent text-primary">
<BrandMark className="size-5" />
</div>
<CardTitle className="truncate text-xl font-semibold tracking-tight">
{event.title}
</CardTitle>
<CardDescription>
{formatEventDate(event.startsAt) ?? "Open gallery"}
</CardDescription>
</CardHeader>
<CardContent>
<span className="text-sm text-primary">View gallery</span>
</CardContent>
</Card>
</Link>
))}
</div>
</section>
) : null}
</main>
);
}
+29
View File
@@ -0,0 +1,29 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ResetPasswordForm } from "./reset-password-form";
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: Promise<{ token?: string; error?: string }>;
}) {
const { token, error } = await searchParams;
return (
<main className="page-pad mx-auto flex min-h-[calc(100dvh-3.5rem)] w-full max-w-md items-center py-10 sm:min-h-[calc(100dvh-4rem)]">
<Card className="reveal w-full">
<CardHeader>
<CardTitle className="text-2xl font-semibold tracking-tight">Choose a new password</CardTitle>
<CardDescription>This link expires one hour after it is sent.</CardDescription>
</CardHeader>
<CardContent>
<ResetPasswordForm token={token} invalid={Boolean(error)} />
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { KeyRoundIcon } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
export function ResetPasswordForm({
token,
invalid,
}: {
token?: string;
invalid: boolean;
}) {
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [loading, setLoading] = useState(false);
const [complete, setComplete] = useState(false);
const [error, setError] = useState<string | null>(
invalid || !token ? "This reset link is invalid or has expired." : null,
);
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) return;
if (password !== confirmation) {
setError("Passwords do not match.");
return;
}
setLoading(true);
setError(null);
const result = await authClient.resetPassword({
token,
newPassword: password,
});
setLoading(false);
if (result.error) {
setError(result.error.message ?? "This reset link is invalid or expired.");
return;
}
setComplete(true);
}
if (complete) {
return (
<div className="flex flex-col gap-5">
<Alert>
<AlertTitle>Password updated</AlertTitle>
<AlertDescription>
Other sessions were signed out to protect your account.
</AlertDescription>
</Alert>
<Button asChild className="w-full">
<Link href="/sign-in">Sign in with new password</Link>
</Button>
</div>
);
}
return (
<form className="flex flex-col gap-5" onSubmit={submit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="new-password">New password</FieldLabel>
<Input
id="new-password"
type="password"
autoComplete="new-password"
minLength={10}
disabled={!token || invalid}
required
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="confirm-password">Confirm new password</FieldLabel>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
minLength={10}
disabled={!token || invalid}
required
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<Button
type="submit"
size="lg"
className="w-full"
disabled={loading || !token || invalid}
>
{loading ? (
<Spinner data-icon="inline-start" />
) : (
<KeyRoundIcon data-icon="inline-start" />
)}
Save new password
</Button>
{invalid || !token ? (
<Button asChild variant="outline" className="w-full">
<Link href="/forgot-password">Request another link</Link>
</Button>
) : null}
</form>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { auth } from "@/server/auth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { SignInForm } from "./sign-in-form";
export default async function SignInPage({
searchParams,
}: {
searchParams: Promise<{ callbackURL?: string }>;
}) {
const { callbackURL } = await searchParams;
const safeCallback =
callbackURL?.startsWith("/") && !callbackURL.startsWith("//")
? callbackURL
: "/dashboard";
const session = await auth.api.getSession({ headers: await headers() });
if (session) redirect(safeCallback);
return (
<main className="page-pad mx-auto flex min-h-[calc(100dvh-3.5rem)] w-full max-w-md items-center py-10 sm:min-h-[calc(100dvh-4rem)]">
<Card className="reveal w-full">
<CardHeader>
<CardTitle className="text-2xl font-semibold tracking-tight">Sign in</CardTitle>
<CardDescription>Access your Vellum host account.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<SignInForm callbackURL={safeCallback} />
<p className="text-center text-sm text-muted-foreground">
Need an account?{" "}
<Link
href={`/sign-up?callbackURL=${encodeURIComponent(safeCallback)}`}
className="font-medium text-primary hover:underline"
>
Create one
</Link>
</p>
</CardContent>
</Card>
</main>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { LogInIcon } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
const SIGN_IN_ERROR_MESSAGE =
"Unable to sign in. Check your email and password, then try again.";
export function SignInForm({ callbackURL }: { callbackURL: string }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
try {
const result = await authClient.signIn.email({
email,
password,
rememberMe: true,
});
if (result.error) {
setError(SIGN_IN_ERROR_MESSAGE);
return;
}
router.push(callbackURL);
router.refresh();
} catch {
setError(SIGN_IN_ERROR_MESSAGE);
} finally {
setLoading(false);
}
}
return (
<form className="flex flex-col gap-5" onSubmit={submit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email address</FieldLabel>
<Input
id="email"
type="email"
autoComplete="email"
autoFocus
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@example.com"
className="tap-target"
/>
</Field>
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel htmlFor="password">Password</FieldLabel>
<Link
href="/forgot-password"
className="text-xs font-medium text-primary hover:underline"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
className="tap-target"
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<Button className="tap-target w-full" size="lg" disabled={loading} type="submit">
{loading ? (
<Spinner data-icon="inline-start" />
) : (
<LogInIcon data-icon="inline-start" />
)}
Sign in
</Button>
</form>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/server/auth";
import { getDeploymentSettings } from "@/server/settings";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { SignUpForm } from "./sign-up-form";
export default async function SignUpPage({
searchParams,
}: {
searchParams: Promise<{ callbackURL?: string; invite?: string; code?: string }>;
}) {
const { callbackURL, invite, code } = await searchParams;
const token = invite ?? code;
const safeCallback =
callbackURL?.startsWith("/") && !callbackURL.startsWith("//")
? callbackURL
: token
? `/invitations/${token}`
: "/dashboard";
const session = await auth.api.getSession({ headers: await headers() });
if (session) redirect(safeCallback);
const settings = await getDeploymentSettings();
return (
<main className="page-pad mx-auto flex min-h-[calc(100dvh-3.5rem)] w-full max-w-md items-center py-10 sm:min-h-[calc(100dvh-4rem)]">
<Card className="reveal w-full">
<CardHeader>
<CardTitle className="text-2xl font-semibold tracking-tight">Create an account</CardTitle>
<CardDescription>
{settings.openSignup
? "Host an event and share a guest upload link."
: "This deployment is invite-only. Use a code or invite link."}
</CardDescription>
</CardHeader>
<CardContent>
<SignUpForm
callbackURL={safeCallback}
requireInvite={!settings.openSignup}
initialCode={token ?? ""}
/>
</CardContent>
</Card>
</main>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { UserPlusIcon } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
export function SignUpForm({
callbackURL,
requireInvite,
initialCode = "",
}: {
callbackURL: string;
requireInvite: boolean;
initialCode?: string;
}) {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [inviteCode, setInviteCode] = useState(initialCode);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (requireInvite && !inviteCode.trim()) {
setError("An invite code is required");
return;
}
setLoading(true);
setError(null);
const result = await authClient.signUp.email({
name,
email,
password,
callbackURL,
});
if (result.error) {
setError(result.error.message ?? "Unable to create account");
setLoading(false);
return;
}
if (inviteCode.trim()) {
router.push(`/invitations/${encodeURIComponent(inviteCode.trim())}`);
router.refresh();
return;
}
router.push(callbackURL);
router.refresh();
}
return (
<form className="flex flex-col gap-5" onSubmit={submit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input
id="name"
autoComplete="name"
required
value={name}
onChange={(event) => setName(event.target.value)}
className="tap-target"
/>
</Field>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="tap-target"
/>
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input
id="password"
type="password"
autoComplete="new-password"
minLength={process.env.NODE_ENV === "production" ? 10 : 5}
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="tap-target"
/>
<FieldDescription>Use at least 10 characters in production.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="invite">
Invite code {requireInvite ? "" : "(optional)"}
</FieldLabel>
<Input
id="invite"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
placeholder="VELLUM-…"
className="tap-target"
required={requireInvite}
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<Button type="submit" size="lg" className="tap-target w-full" disabled={loading}>
{loading ? (
<Spinner data-icon="inline-start" />
) : (
<UserPlusIcon data-icon="inline-start" />
)}
Create account
</Button>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href={`/sign-in?callbackURL=${encodeURIComponent(callbackURL)}`}
className="font-medium text-primary hover:underline"
>
Sign in
</Link>
</p>
</form>
);
}