commit 27e2f196ebc5c3f534c53957fb9d58e128db3bc8 Author: Sean O'Connor Date: Mon Sep 7 19:36:14 2026 -0400 Initial commit of Vellum, an event photo product for guest uploads, host moderation, and original-quality galleries. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e96ce14 --- /dev/null +++ b/.env.example @@ -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 +SMTP_HOST=127.0.0.1 +SMTP_PORT=1027 +RESEND_API_KEY= +RESEND_FROM=Album + +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cc9fe8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +.turbo +.next +dist +.env +.env.local +*.log +*.tsbuildinfo +coverage +.secrets/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7a1dd13 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..70f4305 --- /dev/null +++ b/README.md @@ -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 diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..2a42785 --- /dev/null +++ b/apps/web/components.json @@ -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": {} +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..c4b7818 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +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. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..c530ab3 --- /dev/null +++ b/apps/web/next.config.ts @@ -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; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..5e20d57 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..c2ddf74 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx new file mode 100644 index 0000000..3b84367 --- /dev/null +++ b/apps/web/src/app/admin/layout.tsx @@ -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 ( + <> +
+
+ + + +
+
{children}
+
+ + + ); +} diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx new file mode 100644 index 0000000..50e1db4 --- /dev/null +++ b/apps/web/src/app/admin/page.tsx @@ -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 ( +
+
+

Platform

+

+ Full access for deployment operators. +

+
+
+ + + Groups + {groups.length} groups + + + {groups.map((group) => ( +
+ {group.name} + + {group.quota.unlimited + ? "unlimited" + : `${group.quota.used}/${group.quota.eventLimit ?? 0}`} + {group.quota.complimentary ? " · comp" : ""} + +
+ ))} +
+
+ + + Events + {events.length} events + + + {events.map((event) => ( +
+ {event.title} +
+ {event.status} + {event.listed ? listed : null} +
+
+ ))} +
+
+
+ {canManageUsers ? : null} + {canManageUsers ? ( + ({ id: group.id, name: group.name }))} /> + ) : null} + + + Audit + + +
    + {audit.slice(0, 40).map((row) => ( +
  • + + {row.action} · {row.subjectType} + + + {new Date(row.createdAt).toLocaleString()} + +
  • + ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/admin/platform-codes.tsx b/apps/web/src/app/admin/platform-codes.tsx new file mode 100644 index 0000000..e8c69bd --- /dev/null +++ b/apps/web/src/app/admin/platform-codes.tsx @@ -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(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 ( + + + Invites and entitlements + + One-time or reusable codes, and complimentary event access. + + + +
+ + +
+ {code ? ( +

+ Share once: {code} +

+ ) : null} + {groups.length > 0 ? ( +
+ + +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/admin/platform-users.tsx b/apps/web/src/app/admin/platform-users.tsx new file mode 100644 index 0000000..420681c --- /dev/null +++ b/apps/web/src/app/admin/platform-users.tsx @@ -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 = [ + "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 ( + + + Users + Grant platform roles. + + + setQuery(event.target.value)} + /> +
    + {(users.data ?? []).map((row) => ( +
  • +
    +

    {row.name}

    +

    {row.email}

    +
    +
    + {row.platformRole ? ( + {row.platformRole} + ) : null} + +
    +
  • + ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/settings/deployment-settings-form.tsx b/apps/web/src/app/admin/settings/deployment-settings-form.tsx new file mode 100644 index 0000000..109ff6c --- /dev/null +++ b/apps/web/src/app/admin/settings/deployment-settings-form.tsx @@ -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 ( + + + Access + + Open signup is for accounts. Event creation can still require an invite + or an administrator. + + + +
{ + event.preventDefault(); + update.mutate({ + openSignup: open, + eventCreatePolicy: policy, + defaultEventLimit: Number(limit), + }); + }} + > + + + Open signups + + + + Event creation + + + + Default event limit for new groups + setLimit(event.target.value)} + /> + + + +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/settings/page.tsx b/apps/web/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..54f06b7 --- /dev/null +++ b/apps/web/src/app/admin/settings/page.tsx @@ -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 ( +
+
+

Deployment settings

+

+ Signup policy and event creation rules for this install. +

+
+ +
+ ); +} diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..f8b0608 --- /dev/null +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from "@/server/auth"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..64bc284 --- /dev/null +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -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> | 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 }; diff --git a/apps/web/src/app/apple-icon.png b/apps/web/src/app/apple-icon.png new file mode 100644 index 0000000..82c0c7d Binary files /dev/null and b/apps/web/src/app/apple-icon.png differ diff --git a/apps/web/src/app/dashboard/create-event-dialog.tsx b/apps/web/src/app/dashboard/create-event-dialog.tsx new file mode 100644 index 0000000..908bca1 --- /dev/null +++ b/apps/web/src/app/dashboard/create-event-dialog.tsx @@ -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 ( + + + + + +
{ + event.preventDefault(); + createEvent.mutate({ + title, + description: description.trim() || undefined, + inviteCode: inviteCode.trim() || undefined, + }); + }} + > + + Create event + + Guests will use a shareable link after you publish it. + + + + + Title + setTitle(event.target.value)} + placeholder="Maya and Jonah" + /> + + + Description +