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:
@@ -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
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
.turbo
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
coverage
|
||||||
|
.secrets/
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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))} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient();
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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("; ");
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user