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>
);
}