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
+22
View File
@@ -0,0 +1,22 @@
DATABASE_URL=postgres://album:album@localhost:5439/album
NEXT_PUBLIC_APP_URL=http://localhost:3000
BETTER_AUTH_SECRET=album-development-secret-change-me
BETTER_AUTH_URL=http://localhost:3000
EMAIL_PROVIDER=mailpit
EMAIL_FROM=Album <photos@album.test>
SMTP_HOST=127.0.0.1
SMTP_PORT=1027
RESEND_API_KEY=
RESEND_FROM=Album <photos@album.test>
S3_ENDPOINT=http://127.0.0.1:3900
S3_PUBLIC_ENDPOINT=http://127.0.0.1:3900
S3_REGION=garage
S3_BUCKET=album
S3_ACCESS_KEY=GK0123456789abcdef0123456789abcdef
S3_SECRET_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
S3_FORCE_PATH_STYLE=true
WORKER_CONCURRENCY=1
WORKER_MIN_INTERVAL_MS=250
+11
View File
@@ -0,0 +1,11 @@
node_modules
.turbo
.next
dist
.env
.env.local
*.log
*.tsbuildinfo
coverage
.secrets/
.DS_Store
+17
View File
@@ -0,0 +1,17 @@
# Vellum repository guidance
- Use Bun exclusively for dependency installation and project scripts. Do not
introduce npm, pnpm, or Yarn lockfiles or commands.
- Keep event-owned database reads and writes scoped by `event_id` (or the
hosting `user_id` for event records).
- Store timestamps as timezone-aware values.
- Do not import `@album/database` into client components.
- Cross-application payloads belong in `@album/contracts`, not in duplicated
local interfaces.
- Never log contributor names, emails, or object keys in bulk. Log photo and
event ids only.
- Send testing email only to Mailpit through the non-production mail
configuration. Never send tests through a production provider.
- Guest gallery queries must return approved photos only.
- Uploads go directly to object storage via short-lived presigned PUTs. Do not
stream original files through Next.js.
+48
View File
@@ -0,0 +1,48 @@
# Vellum
Event photo collection for a wedding day — and for any gathering that needs
the same guest-upload, host-moderate, public-gallery loop.
Guests open a shareable event link, optionally add a name, email, and note,
and upload photos. Event people approve what appears in the gallery.
Originals stay full quality in S3-compatible object storage.
## Repository
```text
apps/
web/ Public site, guest pages, dashboard, platform admin
worker/ image variants, EXIF/GPS strip, HEIC conversion
packages/
contracts/ shared Zod payloads
database/ Drizzle schema, migrations, seed
storage/ S3/Garage client, object keys, presign
email/ Mailpit (dev) / Resend (prod) for auth and guest mail
```
## Quick start
Requirements: Bun 1.3+ and Docker.
```bash
cp .env.example .env
bun install
bun run docker:up
bun run db:migrate
bun run auth:seed
bun run db:seed
bun run dev
```
The app runs at `http://localhost:3000`. Garage S3 is at
`http://localhost:3900`. Mailpit is at `http://localhost:8027`.
- Public: `/` (listed events) and `/e/demo`
- Dashboard: `/dashboard`
- Platform: `/admin`
Example accounts (password `host`, admin password `admin`):
- `admin@example.com` — platform super-admin
- `host@example.com` / `partner@example.com` — event owners
- `manager@example.com` — event manager
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+32
View File
@@ -0,0 +1,32 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
transpilePackages: [
"@album/contracts",
"@album/database",
"@album/email",
"@album/storage",
],
async headers() {
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), geolocation=(), microphone=(), payment=()",
},
];
if (process.env.NODE_ENV === "production") {
securityHeaders.push({
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains",
});
}
return [{ source: "/(.*)", headers: securityHeaders }];
},
};
export default nextConfig;
+52
View File
@@ -0,0 +1,52 @@
{
"name": "@album/web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "bun --env-file=../../.env x next dev --turbopack",
"build": "bun --env-file=../../.env x next build",
"start": "bun --env-file=../../.env x next start",
"auth:seed": "bun --env-file=../../.env src/server/seed-auth.ts",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"dependencies": {
"@album/contracts": "workspace:*",
"@album/database": "workspace:*",
"@album/email": "workspace:*",
"@album/storage": "workspace:*",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.4.3",
"@trpc/react-query": "^11.4.3",
"@trpc/server": "^11.4.3",
"better-auth": "1.6.24",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.2.6",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.45.2",
"lucide-react": "^0.468.0",
"next": "16.2.12",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.7",
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"shadcn": "^4.21.0",
"sonner": "^2.0.7",
"superjson": "^2.2.2",
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.67"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
"@types/bun": "^1.3.14",
"@types/node": "^22.15.32",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"postcss": "^8.5.22",
"tailwindcss": "^4.1.10",
"typescript": "^5.8.3"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+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>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { cn } from "@/lib/utils";
import { BRAND_NAME } from "@/lib/brand";
export function BrandMark({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 32 32"
fill="none"
aria-hidden="true"
className={cn("size-7 shrink-0", className)}
>
<path
fill="currentColor"
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>
);
}
export function BrandLockup({
className,
markClassName,
wordmarkClassName,
}: {
className?: string;
markClassName?: string;
wordmarkClassName?: string;
}) {
return (
<span className={cn("inline-flex items-center gap-2", className)}>
<BrandMark className={cn("text-primary", markClassName)} />
<span
className={cn("font-semibold tracking-tight", wordmarkClassName)}
translate="no"
>
{BRAND_NAME}
</span>
</span>
);
}
@@ -0,0 +1,92 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CalendarIcon, SettingsIcon, ShieldIcon, UsersIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export function DashboardTabBar({
showAdmin = false,
area = "dashboard",
}: {
showAdmin?: boolean;
area?: "dashboard" | "admin";
}) {
const pathname = usePathname();
const items =
area === "admin"
? [
{
href: "/admin",
label: "Overview",
icon: ShieldIcon,
match: (path: string) => path === "/admin",
},
{
href: "/admin/settings",
label: "Settings",
icon: SettingsIcon,
match: (path: string) => path.startsWith("/admin/settings"),
},
{
href: "/dashboard",
label: "Events",
icon: CalendarIcon,
match: (path: string) => path.startsWith("/dashboard"),
},
]
: [
{
href: "/dashboard",
label: "Events",
icon: CalendarIcon,
match: (path: string) =>
path === "/dashboard" || path.startsWith("/dashboard/events"),
},
{
href: "/dashboard/people",
label: "People",
icon: UsersIcon,
match: (path: string) => path.startsWith("/dashboard/people"),
},
...(showAdmin
? [
{
href: "/admin",
label: "Admin",
icon: ShieldIcon,
match: (path: string) => path.startsWith("/admin"),
},
]
: []),
];
return (
<nav
className="fixed inset-x-0 bottom-0 z-40 border-t border-border/70 bg-background/90 pb-[env(safe-area-inset-bottom)] backdrop-blur-md sm:hidden"
aria-label={area === "admin" ? "Admin" : "Dashboard"}
>
<ul className="mx-auto grid max-w-lg grid-cols-[repeat(auto-fit,minmax(0,1fr))] px-1 py-1">
{items.map((item) => {
const active = item.match(pathname);
const Icon = item.icon;
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
"flex min-h-12 flex-col items-center justify-center gap-0.5 rounded-xl text-[11px] font-medium transition-colors duration-200",
active ? "bg-accent text-accent-foreground" : "text-muted-foreground",
)}
aria-current={active ? "page" : undefined}
>
<Icon aria-hidden="true" className="size-5" />
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}
@@ -0,0 +1,46 @@
"use client";
import type { ReactNode } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export type EventWorkspaceTab = {
id: string;
label: string;
content: ReactNode;
};
export function EventWorkspace({
heading,
tabs,
}: {
heading: ReactNode;
tabs: EventWorkspaceTab[];
}) {
const initial = tabs[0]?.id ?? "photos";
return (
<div className="reveal flex flex-col gap-6 sm:gap-8">
{heading}
{tabs.length === 0 ? null : (
<Tabs defaultValue={initial} className="gap-5">
<TabsList className="sticky top-[calc(3.5rem+env(safe-area-inset-top))] z-30 h-11 w-full max-w-full justify-start overflow-x-auto bg-muted/90 backdrop-blur-md sm:top-[calc(4rem+env(safe-area-inset-top))] sm:h-10">
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="tap-target flex-none px-3"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="flex flex-col gap-4">
{tab.content}
</TabsContent>
))}
</Tabs>
)}
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "@/trpc/react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function GroupSwitcher({
groups,
activeGroupId,
}: {
groups: { id: string; name: string }[];
activeGroupId: string | null;
}) {
const router = useRouter();
const select = api.group.select.useMutation({
onSuccess: () => router.refresh(),
});
if (groups.length === 0) return null;
const value = activeGroupId ?? groups[0]?.id;
return (
<Select
value={value}
onValueChange={(groupId) => select.mutate({ groupId })}
>
<SelectTrigger
aria-label="Active group"
className="tap-target w-full min-w-0 max-w-full sm:w-auto sm:max-w-56"
>
<SelectValue placeholder="Group" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}
@@ -0,0 +1,24 @@
"use client";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
export function SignOutButton() {
const router = useRouter();
return (
<Button
type="button"
variant="ghost"
size="sm"
className="tap-target"
onClick={async () => {
await authClient.signOut();
router.push("/");
router.refresh();
}}
>
Sign out
</Button>
);
}
@@ -0,0 +1,99 @@
"use client";
import Link from "next/link";
import { MenuIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { SignOutButton } from "@/components/sign-out-button";
import { ThemeToggle } from "@/components/theme-toggle";
import { BrandLockup } from "@/components/brand-mark";
import { BRAND_NAME } from "@/lib/brand";
type HeaderLink = {
href: string;
label: string;
};
export function SiteHeaderBar({
links,
primary,
signedIn,
}: {
links: HeaderLink[];
primary?: HeaderLink | null;
signedIn: boolean;
}) {
return (
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/80 pt-[env(safe-area-inset-top)] backdrop-blur-md">
<div className="page-pad mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 sm:h-16">
<Link
href="/"
className="text-xl sm:text-2xl"
aria-label={`${BRAND_NAME} home`}
>
<BrandLockup markClassName="size-6 sm:size-7" />
</Link>
<nav className="hidden items-center gap-1 sm:flex">
{links.map((link) => (
<Button key={link.href} asChild variant="ghost" size="sm">
<Link href={link.href}>{link.label}</Link>
</Button>
))}
{primary ? (
<Button asChild size="sm">
<Link href={primary.href}>{primary.label}</Link>
</Button>
) : null}
<ThemeToggle />
{signedIn ? <SignOutButton /> : null}
</nav>
<div className="flex items-center gap-1 sm:hidden">
<ThemeToggle />
<Sheet>
<SheetTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-lg"
className="tap-target"
aria-label="Open menu"
>
<MenuIcon aria-hidden="true" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[min(20rem,90vw)]">
<SheetHeader>
<SheetTitle className="text-2xl">
<BrandLockup />
</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-2 px-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{links.map((link) => (
<Button asChild key={link.href} variant="ghost" className="tap-target justify-start">
<Link href={link.href}>{link.label}</Link>
</Button>
))}
{primary ? (
<Button asChild className="tap-target justify-start">
<Link href={primary.href}>{primary.label}</Link>
</Button>
) : null}
{signedIn ? (
<div className="pt-2">
<SignOutButton />
</div>
) : null}
</nav>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { headers } from "next/headers";
import { auth } from "@/server/auth";
import { getPlatformRole } from "@/server/roles";
import { getDeploymentSettings } from "@/server/settings";
import { SiteHeaderBar } from "@/components/site-header-bar";
export async function SiteHeader() {
const session = await auth.api.getSession({ headers: await headers() });
const platformRole = session ? await getPlatformRole(session.user.id) : null;
const settings = await getDeploymentSettings();
if (session) {
return (
<SiteHeaderBar
signedIn
links={[
{ href: "/dashboard", label: "Dashboard" },
...(platformRole ? [{ href: "/admin", label: "Admin" }] : []),
]}
/>
);
}
return (
<SiteHeaderBar
signedIn={false}
links={[{ href: "/sign-in", label: "Sign in" }]}
primary={
settings.openSignup
? { href: "/sign-up", label: "Host an event" }
: { href: "/sign-up", label: "Have an invite?" }
}
/>
);
}
@@ -0,0 +1,11 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</NextThemesProvider>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { MoonIcon, SunIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const dark = mounted && resolvedTheme === "dark";
return (
<Button
type="button"
variant="ghost"
size="icon-lg"
className="tap-target"
aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
disabled={!mounted}
onClick={() => setTheme(dark ? "light" : "dark")}
>
{dark ? <SunIcon /> : <MoonIcon />}
</Button>
);
}
+75
View File
@@ -0,0 +1,75 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+102
View File
@@ -0,0 +1,102 @@
import * as React from "react"
import { cn } from "cn"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+168
View File
@@ -0,0 +1,168 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+103
View File
@@ -0,0 +1,103 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+23
View File
@@ -0,0 +1,23 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Label as LabelPrimitive } from "radix-ui"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Progress as ProgressPrimitive } from "radix-ui"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="size-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
+191
View File
@@ -0,0 +1,191 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Select as SelectPrimitive } from "radix-ui"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+27
View File
@@ -0,0 +1,27 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+147
View File
@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Dialog as SheetPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "cn"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+10
View File
@@ -0,0 +1,10 @@
import { cn } from "cn"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Switch as SwitchPrimitive } from "radix-ui"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+89
View File
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Tabs as TabsPrimitive } from "radix-ui"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+15
View File
@@ -0,0 +1,15 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { config } from "dotenv";
export async function register() {
for (const path of [
resolve(process.cwd(), ".env"),
resolve(process.cwd(), "../../.env"),
]) {
if (existsSync(path)) {
config({ path, override: false });
break;
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
+3
View File
@@ -0,0 +1,3 @@
export const BRAND_NAME = "Vellum";
export const BRAND_TAGLINE = "Photos from the day";
export const BRAND_TITLE = "Vellum — photos from the day";
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import {
canTransitionProcessing,
canTransitionVisibility,
} from "./photo-status";
describe("photo processing", () => {
test("uploading photos cannot skip processing", () => {
expect(canTransitionProcessing("uploading", "ready")).toBe(false);
expect(canTransitionProcessing("uploading", "processing")).toBe(true);
});
test("processing can become ready", () => {
expect(canTransitionProcessing("processing", "ready")).toBe(true);
});
});
describe("photo visibility", () => {
test("pending photos can be made public, hidden, private, or rejected", () => {
expect(canTransitionVisibility("pending", "public")).toBe(true);
expect(canTransitionVisibility("pending", "hidden")).toBe(true);
expect(canTransitionVisibility("pending", "private")).toBe(true);
expect(canTransitionVisibility("pending", "rejected")).toBe(true);
});
});
+30
View File
@@ -0,0 +1,30 @@
import type { PhotoProcessingStatus, PhotoVisibility } from "@album/contracts";
const processing: Record<PhotoProcessingStatus, PhotoProcessingStatus[]> = {
uploading: ["processing"],
processing: ["ready", "failed"],
ready: [],
failed: ["processing"],
};
const visibility: Record<PhotoVisibility, PhotoVisibility[]> = {
pending: ["public", "hidden", "private", "rejected"],
public: ["hidden", "private", "rejected"],
hidden: ["public", "private", "rejected"],
private: ["public", "hidden", "rejected"],
rejected: ["public", "hidden", "private"],
};
export function canTransitionProcessing(
from: PhotoProcessingStatus,
to: PhotoProcessingStatus,
) {
return processing[from].includes(to);
}
export function canTransitionVisibility(
from: PhotoVisibility,
to: PhotoVisibility,
) {
return visibility[from].includes(to);
}
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test";
import { slugify } from "./slug";
describe("slugify", () => {
test("lowercases and hyphenates titles", () => {
expect(slugify("Maya & Jonah Wedding")).toBe("maya-jonah-wedding");
});
test("falls back when the title has no letters", () => {
expect(slugify("!!!").startsWith("event-")).toBe(true);
});
});
+10
View File
@@ -0,0 +1,10 @@
export function slugify(input: string) {
const slug = input
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
return slug.length >= 3 ? slug : `event-${crypto.randomUUID().slice(0, 8)}`;
}
+46
View File
@@ -0,0 +1,46 @@
import { allowedImageTypes, type AllowedImageType, MAX_PHOTO_BYTES } from "@album/contracts";
const allowed = new Set<string>(allowedImageTypes);
export function imageContentType(file: File): AllowedImageType | null {
if (file.type && allowed.has(file.type)) {
return file.type as AllowedImageType;
}
const name = file.name.toLowerCase();
if (name.endsWith(".heic")) return "image/heic";
if (name.endsWith(".heif")) return "image/heif";
if (name.endsWith(".png")) return "image/png";
if (name.endsWith(".webp")) return "image/webp";
if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg";
return null;
}
export function isAllowedPhoto(file: File) {
return Boolean(imageContentType(file)) && file.size > 0 && file.size <= MAX_PHOTO_BYTES;
}
function putWithProgress(
url: string,
file: File,
contentType: string,
onProgress: (percent: number) => void,
) {
return new Promise<void>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("PUT", url);
request.setRequestHeader("Content-Type", contentType);
request.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
request.onload = () => {
if (request.status >= 200 && request.status < 300) resolve();
else reject(new Error(`Upload failed (${request.status})`));
};
request.onerror = () => reject(new Error("Upload failed"));
request.send(file);
});
}
export { putWithProgress };
+13
View File
@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatEventDate(date: Date | string | null | undefined) {
if (!date) return null;
return new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
}).format(new Date(date));
}
+26
View File
@@ -0,0 +1,26 @@
import { createTRPCRouter, publicProcedure } from "./trpc";
import { eventRouter } from "./routers/event";
import { guestRouter } from "./routers/guest";
import { groupRouter } from "./routers/group";
import { invitesRouter } from "./routers/invites";
import { managerRouter } from "./routers/manager";
import { photosRouter } from "./routers/photos";
import { platformRouter } from "./routers/platform";
import { viewerRouter } from "./routers/viewer";
export const appRouter = createTRPCRouter({
health: publicProcedure.query(() => ({
status: "ok" as const,
service: "album-trpc",
})),
viewer: viewerRouter,
event: eventRouter,
guest: guestRouter,
photos: photosRouter,
group: groupRouter,
manager: managerRouter,
platform: platformRouter,
invites: invitesRouter,
});
export type AppRouter = typeof appRouter;
+102
View File
@@ -0,0 +1,102 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import { events, getDb, guests, photos, submissions } from "@album/database";
import { eventSlugSchema } from "@album/contracts";
import { createPresignedGetUrl } from "@album/storage";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const eventRouter = createTRPCRouter({
listed: publicProcedure.query(async () => {
return getDb()
.select({
id: events.id,
slug: events.slug,
title: events.title,
description: events.description,
startsAt: events.startsAt,
endsAt: events.endsAt,
galleryReleasedAt: events.galleryReleasedAt,
})
.from(events)
.where(and(eq(events.listed, true), eq(events.status, "published")))
.orderBy(desc(events.startsAt), desc(events.createdAt));
}),
bySlug: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
const [event] = await getDb()
.select({
id: events.id,
slug: events.slug,
title: events.title,
description: events.description,
startsAt: events.startsAt,
endsAt: events.endsAt,
status: events.status,
listed: events.listed,
uploadEnabled: events.uploadEnabled,
galleryReleasedAt: events.galleryReleasedAt,
})
.from(events)
.where(eq(events.slug, input))
.limit(1);
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
return event;
}),
gallery: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
const [event] = await getDb()
.select({
id: events.id,
status: events.status,
galleryReleasedAt: events.galleryReleasedAt,
})
.from(events)
.where(eq(events.slug, input))
.limit(1);
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
if (!event.galleryReleasedAt) {
return [];
}
const rows = await getDb()
.select({
id: photos.id,
width: photos.width,
height: photos.height,
createdAt: photos.createdAt,
thumbKey: photos.thumbKey,
displayKey: photos.displayKey,
displayName: guests.displayName,
})
.from(photos)
.innerJoin(submissions, eq(photos.submissionId, submissions.id))
.innerJoin(guests, eq(submissions.guestId, guests.id))
.where(
and(
eq(photos.eventId, event.id),
eq(photos.visibility, "public"),
eq(photos.processingStatus, "ready"),
),
)
.orderBy(desc(photos.createdAt));
return Promise.all(
rows.map(async (photo) => ({
id: photo.id,
contributorName: photo.displayName,
width: photo.width,
height: photo.height,
createdAt: photo.createdAt,
thumbUrl: photo.thumbKey
? await createPresignedGetUrl(photo.thumbKey)
: null,
displayUrl: photo.displayKey
? await createPresignedGetUrl(photo.displayKey)
: null,
})),
);
}),
});
+327
View File
@@ -0,0 +1,327 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import {
auditEvents,
eventMemberships,
events,
getDb,
groupMemberships,
groups,
user,
} from "@album/database";
import {
createEmailInviteInputSchema,
createInviteCodeInputSchema,
setGroupMemberInputSchema,
} from "@album/contracts";
import { z } from "zod";
import {
createTRPCRouter,
loadGroupAccess,
protectedProcedure,
requireGroupPermission,
} from "../trpc";
import { GROUP_PERMISSIONS } from "@/server/permissions";
import { getPlatformRole } from "@/server/roles";
import { resolveGroupQuota } from "@/server/entitlements";
import { countGroupOwners } from "@/server/membership";
import { writeAudit } from "@/server/audit";
import { hashToken, newInviteCode, newToken } from "@/server/tokens";
import { sendStaffInviteEmail } from "@album/email";
import { publicAppOrigin } from "@/server/public-app-url";
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
import { invites } from "@album/database";
export const groupRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
return getDb()
.select({
id: groups.id,
name: groups.name,
slug: groups.slug,
role: groupMemberships.role,
createdAt: groups.createdAt,
})
.from(groupMemberships)
.innerJoin(groups, eq(groupMemberships.groupId, groups.id))
.where(eq(groupMemberships.userId, ctx.session.user.id))
.orderBy(desc(groups.createdAt));
}),
get: protectedProcedure
.input(z.object({ groupId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadGroupAccess(
ctx.session.user.id,
input.groupId,
platformRole,
);
const [group] = await getDb()
.select()
.from(groups)
.where(eq(groups.id, input.groupId))
.limit(1);
if (!group) throw new TRPCError({ code: "NOT_FOUND" });
const quota = await resolveGroupQuota(group.id);
return { ...group, quota, permissions: access.permissions, role: access.role };
}),
members: protectedProcedure
.input(z.object({ groupId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
await loadGroupAccess(ctx.session.user.id, input.groupId, platformRole);
return getDb()
.select({
id: groupMemberships.id,
userId: user.id,
name: user.name,
email: user.email,
role: groupMemberships.role,
})
.from(groupMemberships)
.innerJoin(user, eq(groupMemberships.userId, user.id))
.where(eq(groupMemberships.groupId, input.groupId));
}),
setMember: protectedProcedure
.input(setGroupMemberInputSchema)
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadGroupAccess(
ctx.session.user.id,
input.groupId,
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const [target] = input.userId
? await getDb().select().from(user).where(eq(user.id, input.userId)).limit(1)
: await getDb()
.select()
.from(user)
.where(eq(user.email, input.email ?? ""))
.limit(1);
if (!target) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User must have an account before being added",
});
}
const [existing] = await getDb()
.select()
.from(groupMemberships)
.where(
and(
eq(groupMemberships.groupId, input.groupId),
eq(groupMemberships.userId, target.id),
),
)
.limit(1);
if (existing?.role === "owner" && input.role !== "owner") {
const owners = await countGroupOwners(input.groupId);
if (owners <= 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A group must keep at least one owner",
});
}
}
if (existing) {
await getDb()
.update(groupMemberships)
.set({ role: input.role, updatedAt: new Date() })
.where(eq(groupMemberships.id, existing.id));
} else {
await getDb().insert(groupMemberships).values({
groupId: input.groupId,
userId: target.id,
role: input.role,
});
}
await writeAudit({
groupId: input.groupId,
actorUserId: ctx.session.user.id,
action: "group.member.set",
subjectType: "user",
subjectId: target.id,
metadata: { role: input.role },
});
return { ok: true as const };
}),
removeMember: protectedProcedure
.input(z.object({ groupId: z.string().uuid(), userId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadGroupAccess(
ctx.session.user.id,
input.groupId,
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const [existing] = await getDb()
.select()
.from(groupMemberships)
.where(
and(
eq(groupMemberships.groupId, input.groupId),
eq(groupMemberships.userId, input.userId),
),
)
.limit(1);
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
if (existing.role === "owner") {
const owners = await countGroupOwners(input.groupId);
if (owners <= 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A group must keep at least one owner",
});
}
}
const groupEvents = await getDb()
.select({ id: events.id })
.from(events)
.where(eq(events.groupId, input.groupId));
for (const event of groupEvents) {
await getDb()
.delete(eventMemberships)
.where(
and(
eq(eventMemberships.eventId, event.id),
eq(eventMemberships.userId, input.userId),
),
);
}
await getDb()
.delete(groupMemberships)
.where(eq(groupMemberships.id, existing.id));
await writeAudit({
groupId: input.groupId,
actorUserId: ctx.session.user.id,
action: "group.member.remove",
subjectType: "user",
subjectId: input.userId,
});
return { ok: true as const };
}),
inviteEmail: protectedProcedure
.input(createEmailInviteInputSchema)
.mutation(async ({ ctx, input }) => {
if (!input.groupId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "groupId is required" });
}
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadGroupAccess(
ctx.session.user.id,
input.groupId,
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const token = newToken();
await getDb().insert(invites).values({
kind: "email",
email: input.email,
tokenHash: hashToken(token),
groupId: input.groupId,
eventId: input.eventId ?? null,
groupRole: input.groupRole ?? "member",
eventRole: input.eventRole ?? null,
grantUnlimitedEvents: input.grantUnlimitedEvents ?? false,
grantEventLimit: input.grantEventLimit ?? null,
grantComplimentary: input.grantComplimentary ?? false,
createdByUserId: ctx.session.user.id,
expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
});
const url = `${publicAppOrigin()}/invitations/${token}`;
await sendStaffInviteEmail({
to: input.email,
inviterName: ctx.session.user.name,
inviteUrl: url,
});
await writeAudit({
groupId: input.groupId,
eventId: input.eventId,
actorUserId: ctx.session.user.id,
action: "invite.email.create",
subjectType: "invite",
subjectId: input.groupId,
});
return { ok: true as const };
}),
createCode: protectedProcedure
.input(createInviteCodeInputSchema)
.mutation(async ({ ctx, input }) => {
if (!input.groupId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "groupId is required" });
}
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadGroupAccess(
ctx.session.user.id,
input.groupId,
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const code = newInviteCode();
await getDb().insert(invites).values({
kind: "code",
tokenHash: hashToken(code),
reusable: input.reusable ?? false,
maxUses: input.maxUses ?? 1,
groupId: input.groupId,
eventId: input.eventId ?? null,
groupRole: input.groupRole ?? "member",
eventRole: input.eventRole ?? null,
grantUnlimitedEvents: input.grantUnlimitedEvents ?? false,
grantEventLimit: input.grantEventLimit ?? null,
grantComplimentary: input.grantComplimentary ?? false,
createdByUserId: ctx.session.user.id,
expiresAt: input.expiresAt ?? null,
});
await writeAudit({
groupId: input.groupId,
actorUserId: ctx.session.user.id,
action: "invite.code.create",
subjectType: "invite",
subjectId: input.groupId,
});
return { code };
}),
audit: protectedProcedure
.input(z.object({ groupId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
await loadGroupAccess(ctx.session.user.id, input.groupId, platformRole);
return getDb()
.select({
id: auditEvents.id,
action: auditEvents.action,
subjectType: auditEvents.subjectType,
subjectId: auditEvents.subjectId,
metadata: auditEvents.metadata,
createdAt: auditEvents.createdAt,
eventId: auditEvents.eventId,
})
.from(auditEvents)
.where(eq(auditEvents.groupId, input.groupId))
.orderBy(desc(auditEvents.createdAt))
.limit(100);
}),
select: protectedProcedure
.input(z.object({ groupId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
await loadGroupAccess(ctx.session.user.id, input.groupId, platformRole);
ctx.appendSetCookie(
serializeCookie(GROUP_COOKIE, input.groupId, {
maxAge: 60 * 60 * 24 * 365,
}),
);
return { ok: true as const };
}),
});
+115
View File
@@ -0,0 +1,115 @@
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { events, getDb, guests, submissions } from "@album/database";
import { ensureGuestInputSchema, startSubmissionInputSchema } from "@album/contracts";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { guestCookieName, serializeCookie } from "@/server/cookies";
import { hashToken, newToken } from "@/server/tokens";
async function requirePublishedEvent(slug: string) {
const [event] = await getDb()
.select()
.from(events)
.where(eq(events.slug, slug))
.limit(1);
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
return event;
}
export const guestRouter = createTRPCRouter({
ensure: publicProcedure
.input(ensureGuestInputSchema)
.mutation(async ({ ctx, input }) => {
const event = await requirePublishedEvent(input.eventSlug);
const existingToken = ctx.guestTokenForEvent(event.id);
let guest = null;
if (existingToken) {
const [row] = await getDb()
.select()
.from(guests)
.where(eq(guests.tokenHash, hashToken(existingToken)))
.limit(1);
if (row && row.eventId === event.id) guest = row;
}
if (guest) {
const [updated] = await getDb()
.update(guests)
.set({
displayName: input.displayName ?? guest.displayName,
email: input.email ?? guest.email,
notifyWhenReady: input.notifyWhenReady ?? guest.notifyWhenReady,
note:
input.note === undefined ? guest.note : input.note || null,
updatedAt: new Date(),
})
.where(eq(guests.id, guest.id))
.returning();
return {
guestId: updated!.id,
eventId: event.id,
};
}
const token = newToken();
const [created] = await getDb()
.insert(guests)
.values({
eventId: event.id,
displayName: input.displayName ?? null,
email: input.email ?? null,
notifyWhenReady: Boolean(input.notifyWhenReady && input.email),
note: input.note || null,
tokenHash: hashToken(token),
})
.returning();
if (!created) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
ctx.appendSetCookie(
serializeCookie(guestCookieName(event.id), token, {
maxAge: 60 * 60 * 24 * 365,
}),
);
return { guestId: created.id, eventId: event.id };
}),
startSubmission: publicProcedure
.input(startSubmissionInputSchema)
.mutation(async ({ ctx, input }) => {
const event = await requirePublishedEvent(input.eventSlug);
if (event.status === "closed" || !event.uploadEnabled) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Uploads are closed for this event",
});
}
const token = ctx.guestTokenForEvent(event.id);
if (!token) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Guest session is missing",
});
}
const [guest] = await getDb()
.select()
.from(guests)
.where(eq(guests.tokenHash, hashToken(token)))
.limit(1);
if (!guest || guest.eventId !== event.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Guest session is missing",
});
}
const [submission] = await getDb()
.insert(submissions)
.values({
eventId: event.id,
guestId: guest.id,
})
.returning();
if (!submission) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
return { submissionId: submission.id, guestId: guest.id };
}),
});
@@ -0,0 +1,39 @@
import { redeemInviteInputSchema } from "@album/contracts";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { findInviteByToken, redeemInviteForUser } from "@/server/invites";
import { TRPCError } from "@trpc/server";
import { getDeploymentSettings } from "@/server/settings";
export const invitesRouter = createTRPCRouter({
preview: publicProcedure.input(redeemInviteInputSchema).query(async ({ input }) => {
const invite = await findInviteByToken(input.token);
if (!invite) {
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
}
return {
kind: invite.kind,
status: invite.status,
groupRole: invite.groupRole,
eventRole: invite.eventRole,
expiresAt: invite.expiresAt,
remaining: Math.max(0, invite.maxUses - invite.usedCount),
};
}),
redeem: protectedProcedure
.input(redeemInviteInputSchema)
.mutation(async ({ ctx, input }) => {
return redeemInviteForUser({
token: input.token,
userId: ctx.session.user.id,
});
}),
signupAllowed: publicProcedure.query(async () => {
const settings = await getDeploymentSettings();
return {
openSignup: settings.openSignup,
eventCreatePolicy: settings.eventCreatePolicy,
};
}),
});
+725
View File
@@ -0,0 +1,725 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import {
auditEvents,
eventMemberships,
events,
getDb,
groupMemberships,
guests,
photos,
submissions,
user,
} from "@album/database";
import {
createEventInputSchema,
moderatePhotoInputSchema,
moderateSubmissionInputSchema,
setEventMemberInputSchema,
updateEventInputSchema,
} from "@album/contracts";
import {
createPresignedGetUrl,
deletePrefix,
photoObjectPrefix,
} from "@album/storage";
import { sendAlbumReadyEmail } from "@album/email";
import { z } from "zod";
import {
createTRPCRouter,
EVENT_PERMISSIONS,
loadEventAccess,
loadGroupAccess,
protectedProcedure,
requireEventPermission,
requireGroupPermission,
} from "../trpc";
import { GROUP_PERMISSIONS } from "@/server/permissions";
import { MEMBER_VISIBILITIES, PRIVATE_VISIBILITIES } from "@/server/permissions";
import { getPlatformRole } from "@/server/roles";
import { getDeploymentSettings } from "@/server/settings";
import { grantEntitlement, resolveGroupQuota } from "@/server/entitlements";
import { redeemInviteForUser } from "@/server/invites";
import {
countEventOwners,
createGroupForUser,
ensureEventMembership,
uniqueEventSlug,
} from "@/server/membership";
import { writeAudit } from "@/server/audit";
import { canTransitionVisibility } from "@/lib/photo-status";
import { slugify } from "@/lib/slug";
import { publicAppOrigin } from "@/server/public-app-url";
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
import { hasPlatformPermission } from "@/server/roles";
import { PLATFORM_PERMISSIONS } from "@/server/permissions";
async function signedPhotoUrls(photo: {
thumbKey: string | null;
displayKey: string | null;
originalKey: string;
}) {
const [thumbUrl, displayUrl, originalUrl] = await Promise.all([
photo.thumbKey ? createPresignedGetUrl(photo.thumbKey) : null,
photo.displayKey ? createPresignedGetUrl(photo.displayKey) : null,
createPresignedGetUrl(photo.originalKey),
]);
return { thumbUrl, displayUrl, originalUrl };
}
export const managerRouter = createTRPCRouter({
events: protectedProcedure.query(async ({ ctx }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const memberships = await getDb()
.select({
event: events,
role: eventMemberships.role,
})
.from(eventMemberships)
.innerJoin(events, eq(eventMemberships.eventId, events.id))
.where(eq(eventMemberships.userId, ctx.session.user.id))
.orderBy(desc(events.createdAt));
if (ctx.activeGroupId) {
return memberships
.filter((row) => row.event.groupId === ctx.activeGroupId)
.map((row) => ({ ...row.event, role: row.role }));
}
if (platformRole && hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.EVENTS_READ)) {
const all = await getDb().select().from(events).orderBy(desc(events.createdAt));
return all.map((event) => ({ ...event, role: "platform" as const }));
}
return memberships.map((row) => ({ ...row.event, role: row.role }));
}),
event: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { event, access } = await loadEventAccess(
ctx.session.user.id,
input.eventId,
platformRole,
);
return {
...event,
guestUrl: `${publicAppOrigin()}/e/${event.slug}`,
permissions: access.permissions,
role: access.role,
};
}),
createEvent: protectedProcedure
.input(createEventInputSchema)
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const settings = await getDeploymentSettings();
const isPlatformCreator =
platformRole &&
hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE);
if (settings.eventCreatePolicy === "admin_only" && !isPlatformCreator) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only administrators can create events",
});
}
if (input.inviteCode) {
await redeemInviteForUser({
token: input.inviteCode,
userId: ctx.session.user.id,
});
} else if (settings.eventCreatePolicy === "invite" && !isPlatformCreator) {
const [owned] = await getDb()
.select({ id: events.id })
.from(eventMemberships)
.innerJoin(events, eq(eventMemberships.eventId, events.id))
.where(eq(eventMemberships.userId, ctx.session.user.id))
.limit(1);
if (!owned) {
throw new TRPCError({
code: "FORBIDDEN",
message: "An invite code is required to create an event",
});
}
}
let groupId = input.groupId ?? ctx.activeGroupId;
if (!groupId) {
const [owned] = await getDb()
.select({ groupId: groupMemberships.groupId })
.from(groupMemberships)
.where(
and(
eq(groupMemberships.userId, ctx.session.user.id),
eq(groupMemberships.role, "owner"),
),
)
.limit(1);
groupId = owned?.groupId ?? null;
}
if (groupId) {
const { access } = await loadGroupAccess(
ctx.session.user.id,
groupId,
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.EVENTS_CREATE);
} else {
const group = await createGroupForUser({
userId: ctx.session.user.id,
name: `${ctx.session.user.name}'s group`,
});
groupId = group.id;
await grantEntitlement({
groupId,
eventLimit: settings.defaultEventLimit,
complimentary: false,
source: "signup_default",
grantedByUserId: null,
});
ctx.appendSetCookie(
serializeCookie(GROUP_COOKIE, groupId, { maxAge: 60 * 60 * 24 * 365 }),
);
}
if (!isPlatformCreator) {
const quota = await resolveGroupQuota(groupId);
if (!quota.canCreate) {
throw new TRPCError({
code: "FORBIDDEN",
message: "This group has no remaining event slots",
});
}
}
const slug = await uniqueEventSlug(input.slug ?? slugify(input.title));
const [event] = await getDb()
.insert(events)
.values({
groupId,
title: input.title,
slug,
description: input.description ?? null,
startsAt: input.startsAt ?? null,
endsAt: input.endsAt ?? null,
status: "draft",
})
.returning();
if (!event) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
await ensureEventMembership({
eventId: event.id,
userId: ctx.session.user.id,
role: "owner",
groupId,
});
await writeAudit({
groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "event.create",
subjectType: "event",
subjectId: event.id,
});
return event;
}),
updateEvent: protectedProcedure
.input(updateEventInputSchema)
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { event, access } = await loadEventAccess(
ctx.session.user.id,
input.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
const slug = input.slug
? await uniqueEventSlug(input.slug, event.id)
: event.slug;
const [updated] = await getDb()
.update(events)
.set({
title: input.title ?? event.title,
slug,
description:
input.description === undefined ? event.description : input.description,
startsAt: input.startsAt === undefined ? event.startsAt : input.startsAt,
endsAt: input.endsAt === undefined ? event.endsAt : input.endsAt,
status: input.status ?? event.status,
listed: input.listed ?? event.listed,
uploadEnabled: input.uploadEnabled ?? event.uploadEnabled,
updatedAt: new Date(),
})
.where(eq(events.id, event.id))
.returning();
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "event.update",
subjectType: "event",
subjectId: event.id,
});
return updated!;
}),
releaseGallery: protectedProcedure
.input(z.object({ eventId: z.string().uuid(), notifyGuests: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { event, access } = await loadEventAccess(
ctx.session.user.id,
input.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
const releasedAt = event.galleryReleasedAt ?? new Date();
await getDb()
.update(events)
.set({
galleryReleasedAt: releasedAt,
status: event.status === "draft" ? "published" : event.status,
updatedAt: new Date(),
})
.where(eq(events.id, event.id));
let notified = 0;
if (input.notifyGuests !== false) {
const waiting = await getDb()
.select()
.from(guests)
.where(
and(
eq(guests.eventId, event.id),
eq(guests.notifyWhenReady, true),
),
);
const galleryUrl = `${publicAppOrigin()}/e/${event.slug}`;
for (const guest of waiting) {
if (!guest.email || guest.notifiedAt) continue;
await sendAlbumReadyEmail({
to: guest.email,
eventTitle: event.title,
galleryUrl,
});
await getDb()
.update(guests)
.set({ notifiedAt: new Date(), updatedAt: new Date() })
.where(eq(guests.id, guest.id));
notified += 1;
}
}
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "gallery.release",
subjectType: "event",
subjectId: event.id,
metadata: { notified },
});
return { ok: true as const, notified };
}),
photos: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess(
ctx.session.user.id,
input.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_READ);
const canPrivate = access.permissions.includes(
EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ,
);
const allowed = canPrivate ? PRIVATE_VISIBILITIES : MEMBER_VISIBILITIES;
const rows = await getDb()
.select({
photo: photos,
displayName: guests.displayName,
submissionId: submissions.id,
})
.from(photos)
.innerJoin(submissions, eq(photos.submissionId, submissions.id))
.innerJoin(guests, eq(submissions.guestId, guests.id))
.where(eq(photos.eventId, input.eventId))
.orderBy(desc(photos.createdAt));
return Promise.all(
rows
.filter((row) => allowed.includes(row.photo.visibility))
.map(async (row) => ({
...row.photo,
contributorName: row.displayName,
submissionId: row.submissionId,
...(await signedPhotoUrls(row.photo)),
})),
);
}),
moderatePhoto: protectedProcedure
.input(moderatePhotoInputSchema)
.mutation(async ({ ctx, input }) => {
const [photo] = await getDb()
.select()
.from(photos)
.where(eq(photos.id, input.photoId))
.limit(1);
if (!photo) throw new TRPCError({ code: "NOT_FOUND" });
const platformRole = await getPlatformRole(ctx.session.user.id);
const { event, access } = await loadEventAccess(
ctx.session.user.id,
photo.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_MODERATE);
if (photo.processingStatus !== "ready") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Wait until processing finishes",
});
}
if (!canTransitionVisibility(photo.visibility, input.visibility)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Cannot move a ${photo.visibility} photo to ${input.visibility}`,
});
}
if (
input.visibility === "private" &&
!access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ)
) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const [updated] = await getDb()
.update(photos)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(eq(photos.id, photo.id))
.returning();
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "photo.visibility",
subjectType: "photo",
subjectId: photo.id,
metadata: { visibility: input.visibility },
});
return updated!;
}),
moderateSubmission: protectedProcedure
.input(moderateSubmissionInputSchema)
.mutation(async ({ ctx, input }) => {
const [submission] = await getDb()
.select()
.from(submissions)
.where(eq(submissions.id, input.submissionId))
.limit(1);
if (!submission) throw new TRPCError({ code: "NOT_FOUND" });
const platformRole = await getPlatformRole(ctx.session.user.id);
const { event, access } = await loadEventAccess(
ctx.session.user.id,
submission.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_MODERATE);
const rows = await getDb()
.select()
.from(photos)
.where(eq(photos.submissionId, submission.id));
for (const photo of rows) {
if (photo.processingStatus !== "ready") continue;
if (!canTransitionVisibility(photo.visibility, input.visibility)) continue;
await getDb()
.update(photos)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(eq(photos.id, photo.id));
}
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "submission.visibility",
subjectType: "submission",
subjectId: submission.id,
metadata: { visibility: input.visibility, count: rows.length },
});
return { ok: true as const };
}),
deletePhoto: protectedProcedure
.input(z.object({ photoId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const [photo] = await getDb()
.select()
.from(photos)
.where(eq(photos.id, input.photoId))
.limit(1);
if (!photo) throw new TRPCError({ code: "NOT_FOUND" });
const platformRole = await getPlatformRole(ctx.session.user.id);
const { event, access } = await loadEventAccess(
ctx.session.user.id,
photo.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_DELETE);
await deletePrefix(photoObjectPrefix(photo.eventId, photo.id));
await getDb().delete(photos).where(eq(photos.id, photo.id));
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "photo.delete",
subjectType: "photo",
subjectId: photo.id,
});
return { ok: true as const };
}),
deleteSubmission: protectedProcedure
.input(z.object({ submissionId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const [submission] = await getDb()
.select()
.from(submissions)
.where(eq(submissions.id, input.submissionId))
.limit(1);
if (!submission) throw new TRPCError({ code: "NOT_FOUND" });
const platformRole = await getPlatformRole(ctx.session.user.id);
const { event, access } = await loadEventAccess(
ctx.session.user.id,
submission.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_DELETE);
const rows = await getDb()
.select()
.from(photos)
.where(eq(photos.submissionId, submission.id));
for (const photo of rows) {
await deletePrefix(photoObjectPrefix(photo.eventId, photo.id));
}
await getDb().delete(submissions).where(eq(submissions.id, submission.id));
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "submission.delete",
subjectType: "submission",
subjectId: submission.id,
metadata: { count: rows.length },
});
return { ok: true as const };
}),
notes: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess(
ctx.session.user.id,
input.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.NOTES_READ);
return getDb()
.select({
id: guests.id,
displayName: guests.displayName,
note: guests.note,
createdAt: guests.createdAt,
})
.from(guests)
.where(eq(guests.eventId, input.eventId))
.orderBy(desc(guests.createdAt));
}),
members: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess(
ctx.session.user.id,
input.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_READ);
return getDb()
.select({
id: eventMemberships.id,
userId: user.id,
name: user.name,
email: user.email,
role: eventMemberships.role,
})
.from(eventMemberships)
.innerJoin(user, eq(eventMemberships.userId, user.id))
.where(eq(eventMemberships.eventId, input.eventId));
}),
setMember: protectedProcedure
.input(setEventMemberInputSchema)
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { event, access } = await loadEventAccess(
ctx.session.user.id,
input.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_MANAGE);
if (input.role === "owner") {
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER);
}
const [target] = input.userId
? await getDb().select().from(user).where(eq(user.id, input.userId)).limit(1)
: await getDb()
.select()
.from(user)
.where(eq(user.email, input.email ?? ""))
.limit(1);
if (!target) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User must have an account before being added",
});
}
const [existing] = await getDb()
.select()
.from(eventMemberships)
.where(
and(
eq(eventMemberships.eventId, input.eventId),
eq(eventMemberships.userId, target.id),
),
)
.limit(1);
if (existing?.role === "owner" && input.role !== "owner") {
const owners = await countEventOwners(input.eventId);
if (owners <= 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "An event must keep at least one owner",
});
}
}
await ensureEventMembership({
eventId: input.eventId,
userId: target.id,
role: input.role,
groupId: event.groupId,
});
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "event.member.set",
subjectType: "user",
subjectId: target.id,
metadata: { role: input.role },
});
return { ok: true as const };
}),
removeMember: protectedProcedure
.input(z.object({ eventId: z.string().uuid(), userId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { event, access } = await loadEventAccess(
ctx.session.user.id,
input.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_MANAGE);
const [existing] = await getDb()
.select()
.from(eventMemberships)
.where(
and(
eq(eventMemberships.eventId, input.eventId),
eq(eventMemberships.userId, input.userId),
),
)
.limit(1);
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
if (existing.role === "owner") {
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER);
const owners = await countEventOwners(input.eventId);
if (owners <= 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "An event must keep at least one owner",
});
}
}
await getDb()
.delete(eventMemberships)
.where(eq(eventMemberships.id, existing.id));
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "event.member.remove",
subjectType: "user",
subjectId: input.userId,
});
return { ok: true as const };
}),
audit: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess(
ctx.session.user.id,
input.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.AUDIT_READ);
return getDb()
.select({
id: auditEvents.id,
action: auditEvents.action,
subjectType: auditEvents.subjectType,
subjectId: auditEvents.subjectId,
metadata: auditEvents.metadata,
createdAt: auditEvents.createdAt,
actorUserId: auditEvents.actorUserId,
})
.from(auditEvents)
.where(eq(auditEvents.eventId, input.eventId))
.orderBy(desc(auditEvents.createdAt))
.limit(100);
}),
deleteEvent: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { event, access } = await loadEventAccess(
ctx.session.user.id,
input.eventId,
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.EVENT_DELETE);
const rows = await getDb()
.select({ id: photos.id, eventId: photos.eventId })
.from(photos)
.where(eq(photos.eventId, event.id));
for (const photo of rows) {
await deletePrefix(photoObjectPrefix(photo.eventId, photo.id));
}
await getDb().delete(events).where(eq(events.id, event.id));
await writeAudit({
groupId: event.groupId,
actorUserId: ctx.session.user.id,
action: "event.delete",
subjectType: "event",
subjectId: event.id,
metadata: { count: rows.length },
});
return { ok: true as const };
}),
});
+154
View File
@@ -0,0 +1,154 @@
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { events, getDb, photoJobs, photos, submissions } from "@album/database";
import {
completePhotoInputSchema,
createPhotoInputSchema,
MAX_PHOTO_BYTES,
} from "@album/contracts";
import {
createPresignedPutUrl,
headObject,
originalObjectKey,
} from "@album/storage";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { consumeRateLimit } from "@/server/rate-limit";
import { hashToken } from "@/server/tokens";
import { guests } from "@album/database";
export const photosRouter = createTRPCRouter({
create: publicProcedure
.input(createPhotoInputSchema)
.mutation(async ({ ctx, input }) => {
const [event] = await getDb()
.select()
.from(events)
.where(eq(events.slug, input.eventSlug))
.limit(1);
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
if (event.status === "closed" || !event.uploadEnabled) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Uploads are closed for this event",
});
}
const [submission] = await getDb()
.select()
.from(submissions)
.where(eq(submissions.id, input.submissionId))
.limit(1);
if (!submission || submission.eventId !== event.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Submission not found",
});
}
const token = ctx.guestTokenForEvent(event.id);
if (!token) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Guest session is missing",
});
}
const [guest] = await getDb()
.select()
.from(guests)
.where(
and(
eq(guests.id, submission.guestId),
eq(guests.tokenHash, hashToken(token)),
),
)
.limit(1);
if (!guest) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Guest session does not match this submission",
});
}
const limit = await consumeRateLimit({
namespace: `upload:${event.id}`,
identifier: ctx.clientIdentifier,
limit: 40,
windowMs: 10 * 60 * 1000,
});
if (!limit.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many uploads. Try again shortly.",
});
}
const [photo] = await getDb()
.insert(photos)
.values({
eventId: event.id,
submissionId: submission.id,
processingStatus: "uploading",
visibility: "pending",
originalKey: "pending",
contentType: input.contentType,
byteSize: input.byteSize,
})
.returning();
if (!photo) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
const key = originalObjectKey(event.id, photo.id);
await getDb()
.update(photos)
.set({ originalKey: key, updatedAt: new Date() })
.where(eq(photos.id, photo.id));
const uploadUrl = await createPresignedPutUrl({
key,
contentType: input.contentType,
});
return { photoId: photo.id, uploadUrl };
}),
complete: publicProcedure
.input(completePhotoInputSchema)
.mutation(async ({ input }) => {
const [photo] = await getDb()
.select()
.from(photos)
.where(eq(photos.id, input.photoId))
.limit(1);
if (!photo) throw new TRPCError({ code: "NOT_FOUND" });
if (photo.processingStatus !== "uploading") {
return {
photoId: photo.id,
processingStatus: photo.processingStatus,
};
}
const head = await headObject(photo.originalKey);
if (!head) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Upload was not found. Try again.",
});
}
const size = Number(head.ContentLength ?? 0);
if (size <= 0 || size > MAX_PHOTO_BYTES) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "That file is empty or larger than 25 MB.",
});
}
await getDb().transaction(async (tx) => {
await tx
.update(photos)
.set({
processingStatus: "processing",
byteSize: size,
updatedAt: new Date(),
})
.where(eq(photos.id, photo.id));
await tx.insert(photoJobs).values({
photoId: photo.id,
kind: "transcode",
status: "pending",
});
});
return { photoId: photo.id, processingStatus: "processing" as const };
}),
});
+247
View File
@@ -0,0 +1,247 @@
import { TRPCError } from "@trpc/server";
import { desc, eq, ilike, or } from "drizzle-orm";
import {
auditEvents,
deploymentSettings,
events,
getDb,
groups,
platformAdministrators,
user,
} from "@album/database";
import {
createInviteCodeInputSchema,
grantEntitlementInputSchema,
setPlatformRoleInputSchema,
updateDeploymentSettingsInputSchema,
} from "@album/contracts";
import { z } from "zod";
import {
createTRPCRouter,
platformPermissionProcedure,
platformProcedure,
} from "../trpc";
import { PLATFORM_PERMISSIONS } from "@/server/permissions";
import { hasPlatformPermission } from "@/server/roles";
import { getDeploymentSettings } from "@/server/settings";
import { grantEntitlement, resolveGroupQuota } from "@/server/entitlements";
import { writeAudit } from "@/server/audit";
import { hashToken, newInviteCode } from "@/server/tokens";
import { invites } from "@album/database";
export const platformRouter = createTRPCRouter({
settings: platformPermissionProcedure(PLATFORM_PERMISSIONS.SETTINGS_MANAGE).query(
async () => getDeploymentSettings(),
),
updateSettings: platformPermissionProcedure(
PLATFORM_PERMISSIONS.SETTINGS_MANAGE,
)
.input(updateDeploymentSettingsInputSchema)
.mutation(async ({ ctx, input }) => {
const current = await getDeploymentSettings();
const [updated] = await getDb()
.update(deploymentSettings)
.set({
openSignup: input.openSignup ?? current.openSignup,
eventCreatePolicy: input.eventCreatePolicy ?? current.eventCreatePolicy,
defaultEventLimit: input.defaultEventLimit ?? current.defaultEventLimit,
updatedAt: new Date(),
})
.where(eq(deploymentSettings.id, "default"))
.returning();
await writeAudit({
actorUserId: ctx.session.user.id,
action: "deployment.settings",
subjectType: "deployment",
subjectId: "default",
});
return updated!;
}),
users: platformPermissionProcedure(PLATFORM_PERMISSIONS.USERS_MANAGE)
.input(z.object({ query: z.string().trim().max(120).optional() }).optional())
.query(async ({ input }) => {
const q = input?.query?.trim();
const rows = await getDb()
.select({
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt,
})
.from(user)
.where(
q
? or(ilike(user.email, `%${q}%`), ilike(user.name, `%${q}%`))
: undefined,
)
.orderBy(desc(user.createdAt))
.limit(50);
const admins = await getDb().select().from(platformAdministrators);
const roleByUser = new Map(admins.map((row) => [row.userId, row.role]));
return rows.map((row) => ({
...row,
platformRole: roleByUser.get(row.id) ?? null,
}));
}),
setPlatformRole: platformPermissionProcedure(PLATFORM_PERMISSIONS.USERS_MANAGE)
.input(setPlatformRoleInputSchema)
.mutation(async ({ ctx, input }) => {
if (input.role === "super_admin") {
if (!hasPlatformPermission(ctx.platformRole, PLATFORM_PERMISSIONS.GRANT_SUPER_ADMIN)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only a super admin can grant super admin",
});
}
}
if (input.role === "admin") {
const allowed =
ctx.platformRole === "super_admin" || ctx.platformRole === "admin";
if (!allowed) {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
const [existing] = await getDb()
.select()
.from(platformAdministrators)
.where(eq(platformAdministrators.userId, input.userId))
.limit(1);
if (!input.role) {
if (existing) {
await getDb()
.delete(platformAdministrators)
.where(eq(platformAdministrators.id, existing.id));
}
} else if (existing) {
await getDb()
.update(platformAdministrators)
.set({ role: input.role, updatedAt: new Date() })
.where(eq(platformAdministrators.id, existing.id));
} else {
await getDb().insert(platformAdministrators).values({
userId: input.userId,
role: input.role,
});
}
await writeAudit({
actorUserId: ctx.session.user.id,
action: "platform.role.set",
subjectType: "user",
subjectId: input.userId,
metadata: { role: input.role },
});
return { ok: true as const };
}),
groups: platformPermissionProcedure(PLATFORM_PERMISSIONS.EVENTS_READ).query(
async () => {
const rows = await getDb()
.select()
.from(groups)
.orderBy(desc(groups.createdAt));
return Promise.all(
rows.map(async (group) => ({
...group,
quota: await resolveGroupQuota(group.id),
})),
);
},
),
events: platformPermissionProcedure(PLATFORM_PERMISSIONS.EVENTS_READ).query(
async () => {
return getDb()
.select({
id: events.id,
title: events.title,
slug: events.slug,
status: events.status,
listed: events.listed,
groupId: events.groupId,
groupName: groups.name,
createdAt: events.createdAt,
})
.from(events)
.innerJoin(groups, eq(events.groupId, groups.id))
.orderBy(desc(events.createdAt));
},
),
grantEntitlement: platformPermissionProcedure(
PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE,
)
.input(grantEntitlementInputSchema)
.mutation(async ({ ctx, input }) => {
const row = await grantEntitlement({
groupId: input.groupId,
eventLimit: input.eventLimit === undefined ? null : input.eventLimit,
expiresAt: input.expiresAt,
complimentary: input.complimentary ?? true,
source: "platform",
grantedByUserId: ctx.session.user.id,
});
await writeAudit({
groupId: input.groupId,
actorUserId: ctx.session.user.id,
action: "entitlement.grant",
subjectType: "entitlement",
subjectId: row.id,
metadata: {
unlimited: input.eventLimit == null,
complimentary: input.complimentary ?? true,
},
});
return row;
}),
createCode: platformPermissionProcedure(PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE)
.input(createInviteCodeInputSchema)
.mutation(async ({ ctx, input }) => {
const code = newInviteCode();
await getDb().insert(invites).values({
kind: "code",
tokenHash: hashToken(code),
reusable: input.reusable ?? true,
maxUses: input.maxUses ?? 100,
groupId: input.groupId ?? null,
eventId: input.eventId ?? null,
groupRole: input.groupRole ?? "owner",
eventRole: input.eventRole ?? "owner",
grantUnlimitedEvents: input.grantUnlimitedEvents ?? false,
grantEventLimit: input.grantEventLimit ?? 1,
grantComplimentary: input.grantComplimentary ?? true,
createdByUserId: ctx.session.user.id,
expiresAt: input.expiresAt ?? null,
});
await writeAudit({
actorUserId: ctx.session.user.id,
action: "invite.code.create",
subjectType: "invite",
subjectId: "platform",
});
return { code };
}),
audit: platformPermissionProcedure(PLATFORM_PERMISSIONS.AUDIT_READ).query(
async () => {
return getDb()
.select({
id: auditEvents.id,
action: auditEvents.action,
subjectType: auditEvents.subjectType,
subjectId: auditEvents.subjectId,
metadata: auditEvents.metadata,
createdAt: auditEvents.createdAt,
groupId: auditEvents.groupId,
eventId: auditEvents.eventId,
actorUserId: auditEvents.actorUserId,
})
.from(auditEvents)
.orderBy(desc(auditEvents.createdAt))
.limit(200);
},
),
});
+92
View File
@@ -0,0 +1,92 @@
import { and, desc, eq } from "drizzle-orm";
import {
eventMemberships,
events,
getDb,
groupMemberships,
groups,
} from "@album/database";
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
import { getPlatformRole } from "@/server/roles";
import { getDeploymentSettings } from "@/server/settings";
import { z } from "zod";
export const viewerRouter = createTRPCRouter({
me: publicProcedure.query(async ({ ctx }) => {
const settings = await getDeploymentSettings();
if (!ctx.session) {
return {
session: null,
platformRole: null,
groups: [] as {
id: string;
name: string;
slug: string;
role: "owner" | "member";
}[],
activeGroupId: ctx.activeGroupId,
openSignup: settings.openSignup,
eventCreatePolicy: settings.eventCreatePolicy,
};
}
const platformRole = await getPlatformRole(ctx.session.user.id);
const memberships = await getDb()
.select({
id: groups.id,
name: groups.name,
slug: groups.slug,
role: groupMemberships.role,
})
.from(groupMemberships)
.innerJoin(groups, eq(groupMemberships.groupId, groups.id))
.where(eq(groupMemberships.userId, ctx.session.user.id))
.orderBy(desc(groups.createdAt));
return {
session: ctx.session,
platformRole,
groups: memberships,
activeGroupId: ctx.activeGroupId,
openSignup: settings.openSignup,
eventCreatePolicy: settings.eventCreatePolicy,
};
}),
setActiveGroup: protectedProcedure
.input(z.object({ groupId: z.string().uuid().nullable() }))
.mutation(async ({ ctx, input }) => {
ctx.appendSetCookie(
serializeCookie(GROUP_COOKIE, input.groupId ?? "", {
maxAge: input.groupId ? 60 * 60 * 24 * 365 : 0,
}),
);
return { ok: true as const, groupId: input.groupId };
}),
eventAccess: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const [membership] = await getDb()
.select()
.from(eventMemberships)
.where(
and(
eq(eventMemberships.eventId, input.eventId),
eq(eventMemberships.userId, ctx.session.user.id),
),
)
.limit(1);
const [event] = await getDb()
.select({ id: events.id, groupId: events.groupId })
.from(events)
.where(eq(events.id, input.eventId))
.limit(1);
return {
platformRole,
eventRole: membership?.role ?? null,
groupId: event?.groupId ?? null,
};
}),
});
+154
View File
@@ -0,0 +1,154 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { eq } from "drizzle-orm";
import { events, getDb } from "@album/database";
import type { EventRole, GroupRole, PlatformRole } from "@album/contracts";
import { auth } from "@/server/auth";
import { requestClientIdentifier } from "@/server/rate-limit";
import {
GROUP_COOKIE,
guestCookieName,
parseCookieHeader,
} from "@/server/cookies";
import {
EVENT_PERMISSIONS,
type EventPermission,
type GroupPermission,
type PlatformPermission,
} from "@/server/permissions";
import {
getEventMembership,
getGroupMembership,
getPlatformRole,
hasEventPermission,
hasGroupPermission,
hasPlatformPermission,
resolveEventAccess,
resolveGroupAccess,
} from "@/server/roles";
export async function createTRPCContext(options: {
headers: Headers;
requestOrigin: string;
}) {
const session = await auth.api.getSession({ headers: options.headers });
const cookies = parseCookieHeader(options.headers.get("cookie"));
const setCookies: string[] = [];
return {
session,
requestOrigin: options.requestOrigin,
clientIdentifier: requestClientIdentifier(
new Request(options.requestOrigin, { headers: options.headers }),
),
cookies,
activeGroupId: cookies.get(GROUP_COOKIE) ?? null,
guestTokenForEvent: (eventId: string) =>
cookies.get(guestCookieName(eventId)) ?? null,
appendSetCookie: (cookie: string) => {
setCookies.push(cookie);
},
setCookies,
};
}
export type TrpcContext = Awaited<ReturnType<typeof createTRPCContext>>;
const t = initTRPC.context<TrpcContext>().create({
transformer: superjson,
});
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, session: ctx.session } });
});
export const platformProcedure = protectedProcedure.use(async ({ ctx, next }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
if (!platformRole) {
throw new TRPCError({ code: "FORBIDDEN", message: "Platform access required" });
}
return next({ ctx: { ...ctx, platformRole } });
});
export function platformPermissionProcedure(permission: PlatformPermission) {
return platformProcedure.use(({ ctx, next }) => {
if (!hasPlatformPermission(ctx.platformRole, permission)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Missing platform permission",
});
}
return next({ ctx });
});
}
export async function loadGroupAccess(
userId: string,
groupId: string,
platformRole: PlatformRole | null,
) {
const membership = await getGroupMembership(groupId, userId);
const access = resolveGroupAccess({
membershipRole: (membership?.role as GroupRole | undefined) ?? null,
platformRole,
});
if (!access) {
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this group" });
}
return { membership, access };
}
export async function loadEventAccess(
userId: string,
eventId: string,
platformRole: PlatformRole | null,
) {
const [event] = await getDb()
.select()
.from(events)
.where(eq(events.id, eventId))
.limit(1);
if (!event) {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
const membership = await getEventMembership(eventId, userId);
const access = resolveEventAccess({
membershipRole: (membership?.role as EventRole | undefined) ?? null,
platformRole,
});
if (!access) {
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this event" });
}
return { event, membership, access };
}
export function requireEventPermission(
permissions: EventPermission[],
permission: EventPermission,
) {
if (!hasEventPermission(permissions, permission)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Missing event permission",
});
}
}
export function requireGroupPermission(
permissions: GroupPermission[],
permission: GroupPermission,
) {
if (!hasGroupPermission(permissions, permission)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Missing group permission",
});
}
}
export { EVENT_PERMISSIONS };
+25
View File
@@ -0,0 +1,25 @@
import { auditEvents, type Database } from "@album/database";
import { getDb } from "@album/database";
export async function writeAudit(
input: {
groupId?: string | null;
eventId?: string | null;
actorUserId?: string | null;
action: string;
subjectType: string;
subjectId: string;
metadata?: Record<string, string | number | boolean | null>;
},
db: Database = getDb(),
) {
await db.insert(auditEvents).values({
groupId: input.groupId ?? null,
eventId: input.eventId ?? null,
actorUserId: input.actorUserId ?? null,
action: input.action,
subjectType: input.subjectType,
subjectId: input.subjectId,
metadata: input.metadata ?? {},
});
}
+62
View File
@@ -0,0 +1,62 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { getDb } from "@album/database";
import {
sendAccountVerificationEmail,
sendPasswordResetEmail,
} from "@album/email";
const baseURL =
process.env.BETTER_AUTH_URL ??
process.env.NEXT_PUBLIC_APP_URL ??
"http://localhost:3000";
export const auth = betterAuth({
appName: "Vellum",
baseURL,
secret: process.env.BETTER_AUTH_SECRET,
advanced: {
cookiePrefix: "album",
},
database: drizzleAdapter(getDb(), {
provider: "pg",
}),
user: {
changeEmail: {
enabled: true,
},
deleteUser: {
enabled: true,
},
},
emailAndPassword: {
enabled: true,
minPasswordLength: process.env.NODE_ENV === "production" ? 10 : 5,
autoSignIn: true,
resetPasswordTokenExpiresIn: 60 * 60,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, url }) => {
await sendPasswordResetEmail({
to: user.email,
name: user.name,
resetUrl: url,
});
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await sendAccountVerificationEmail({
to: user.email,
name: user.name,
verificationUrl: url,
});
},
},
trustedOrigins: [baseURL],
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
},
});
+41
View File
@@ -0,0 +1,41 @@
export const GROUP_COOKIE = "album_group_id";
export const GUEST_COOKIE_PREFIX = "album_guest_";
export function parseCookieHeader(header: string | null) {
const map = new Map<string, string>();
if (!header) return map;
for (const part of header.split(";")) {
const index = part.indexOf("=");
if (index === -1) continue;
const name = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (name) map.set(name, decodeURIComponent(value));
}
return map;
}
export function guestCookieName(eventId: string) {
return `${GUEST_COOKIE_PREFIX}${eventId}`;
}
export function serializeCookie(
name: string,
value: string,
options: {
httpOnly?: boolean;
maxAge?: number;
path?: string;
sameSite?: "Lax" | "Strict" | "None";
secure?: boolean;
} = {},
) {
const parts = [
`${name}=${encodeURIComponent(value)}`,
`Path=${options.path ?? "/"}`,
`SameSite=${options.sameSite ?? "Lax"}`,
];
if (options.httpOnly !== false) parts.push("HttpOnly");
if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`);
if (options.secure) parts.push("Secure");
return parts.join("; ");
}
+70
View File
@@ -0,0 +1,70 @@
import { count, eq } from "drizzle-orm";
import { entitlements, events, getDb, type Database } from "@album/database";
export function isEntitlementActive(
row: { expiresAt: Date | null },
now = new Date(),
) {
return !row.expiresAt || row.expiresAt > now;
}
export async function resolveGroupQuota(
groupId: string,
db: Database = getDb(),
) {
const rows = await db
.select()
.from(entitlements)
.where(eq(entitlements.groupId, groupId));
const active = rows.filter((row) => isEntitlementActive(row));
const unlimited = active.some((row) => row.eventLimit === null);
const complimentary = active.some((row) => row.complimentary);
const numericLimits = active
.map((row) => row.eventLimit)
.filter((value): value is number => value !== null);
const eventLimit = unlimited
? null
: numericLimits.length > 0
? Math.max(...numericLimits)
: 0;
const [usedRow] = await db
.select({ value: count() })
.from(events)
.where(eq(events.groupId, groupId));
const used = Number(usedRow?.value ?? 0);
return {
unlimited,
eventLimit,
used,
remaining: unlimited
? null
: Math.max(0, (eventLimit ?? 0) - used),
complimentary,
canCreate: unlimited || used < (eventLimit ?? 0),
};
}
export async function grantEntitlement(
input: {
groupId: string;
eventLimit: number | null;
expiresAt?: Date | null;
complimentary?: boolean;
source: "signup_default" | "invite" | "code" | "platform";
grantedByUserId?: string | null;
},
db: Database = getDb(),
) {
const [row] = await db
.insert(entitlements)
.values({
groupId: input.groupId,
eventLimit: input.eventLimit,
expiresAt: input.expiresAt ?? null,
complimentary: input.complimentary ?? false,
source: input.source,
grantedByUserId: input.grantedByUserId ?? null,
})
.returning();
return row!;
}

Some files were not shown because too many files have changed in this diff Show More