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,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!;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
events,
|
||||
getDb,
|
||||
invites,
|
||||
user,
|
||||
type Database,
|
||||
} from "@album/database";
|
||||
import { writeAudit } from "@/server/audit";
|
||||
import { grantEntitlement } from "@/server/entitlements";
|
||||
import {
|
||||
createGroupForUser,
|
||||
ensureEventMembership,
|
||||
ensureGroupMembership,
|
||||
} from "@/server/membership";
|
||||
import { hashToken } from "@/server/tokens";
|
||||
|
||||
export async function findInviteByToken(token: string, db: Database = getDb()) {
|
||||
const tokenHash = hashToken(token);
|
||||
const [invite] = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
return invite ?? null;
|
||||
}
|
||||
|
||||
export function assertInviteRedeemable(invite: typeof invites.$inferSelect) {
|
||||
if (invite.status === "revoked") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite was revoked" });
|
||||
}
|
||||
if (invite.status === "expired") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
|
||||
}
|
||||
if (invite.expiresAt && invite.expiresAt < new Date()) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
|
||||
}
|
||||
if (!invite.reusable && invite.status === "accepted") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invite already used",
|
||||
});
|
||||
}
|
||||
if (invite.usedCount >= invite.maxUses) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invite has no remaining uses",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function redeemInviteForUser(
|
||||
input: { token: string; userId: string },
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const invite = await findInviteByToken(input.token, db);
|
||||
if (!invite) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
||||
}
|
||||
assertInviteRedeemable(invite);
|
||||
|
||||
const [actor] = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, input.userId))
|
||||
.limit(1);
|
||||
if (!actor) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
let groupId = invite.groupId;
|
||||
if (
|
||||
!groupId &&
|
||||
(invite.grantUnlimitedEvents || invite.grantEventLimit || invite.grantComplimentary)
|
||||
) {
|
||||
const group = await createGroupForUser(
|
||||
{ userId: input.userId, name: `${actor.name}'s group` },
|
||||
db,
|
||||
);
|
||||
groupId = group.id;
|
||||
}
|
||||
|
||||
if (groupId && invite.groupRole) {
|
||||
await ensureGroupMembership(
|
||||
{ groupId, userId: input.userId, role: invite.groupRole },
|
||||
db,
|
||||
);
|
||||
} else if (groupId) {
|
||||
await ensureGroupMembership(
|
||||
{ groupId, userId: input.userId, role: "member" },
|
||||
db,
|
||||
);
|
||||
}
|
||||
|
||||
if (invite.eventId && invite.eventRole && groupId) {
|
||||
await ensureEventMembership(
|
||||
{
|
||||
eventId: invite.eventId,
|
||||
userId: input.userId,
|
||||
role: invite.eventRole,
|
||||
groupId,
|
||||
},
|
||||
db,
|
||||
);
|
||||
} else if (invite.eventId && invite.eventRole) {
|
||||
const [event] = await db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.id, invite.eventId))
|
||||
.limit(1);
|
||||
if (event) {
|
||||
await ensureEventMembership(
|
||||
{
|
||||
eventId: event.id,
|
||||
userId: input.userId,
|
||||
role: invite.eventRole,
|
||||
groupId: event.groupId,
|
||||
},
|
||||
db,
|
||||
);
|
||||
groupId = event.groupId;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
groupId &&
|
||||
(invite.grantUnlimitedEvents ||
|
||||
invite.grantEventLimit !== null ||
|
||||
invite.grantComplimentary)
|
||||
) {
|
||||
await grantEntitlement(
|
||||
{
|
||||
groupId,
|
||||
eventLimit: invite.grantUnlimitedEvents ? null : invite.grantEventLimit,
|
||||
complimentary: invite.grantComplimentary,
|
||||
source: invite.kind === "code" ? "code" : "invite",
|
||||
grantedByUserId: invite.createdByUserId,
|
||||
},
|
||||
db,
|
||||
);
|
||||
}
|
||||
|
||||
const usedCount = invite.usedCount + 1;
|
||||
const exhausted = usedCount >= invite.maxUses;
|
||||
await db
|
||||
.update(invites)
|
||||
.set({
|
||||
usedCount,
|
||||
status: exhausted && !invite.reusable ? "accepted" : invite.status,
|
||||
acceptedByUserId: input.userId,
|
||||
acceptedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invites.id, invite.id));
|
||||
|
||||
await writeAudit(
|
||||
{
|
||||
groupId,
|
||||
eventId: invite.eventId,
|
||||
actorUserId: input.userId,
|
||||
action: "invite.redeem",
|
||||
subjectType: "invite",
|
||||
subjectId: invite.id,
|
||||
metadata: { kind: invite.kind },
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
return { groupId, eventId: invite.eventId, inviteId: invite.id };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import {
|
||||
eventMemberships,
|
||||
events,
|
||||
getDb,
|
||||
groupMemberships,
|
||||
groups,
|
||||
type Database,
|
||||
} from "@album/database";
|
||||
import type { EventRole, GroupRole } from "@album/contracts";
|
||||
import { slugify } from "@/lib/slug";
|
||||
|
||||
export async function uniqueGroupSlug(desired: string, db: Database = getDb()) {
|
||||
let candidate = desired.slice(0, 60) || "group";
|
||||
let suffix = 2;
|
||||
while (true) {
|
||||
const [existing] = await db
|
||||
.select({ id: groups.id })
|
||||
.from(groups)
|
||||
.where(eq(groups.slug, candidate))
|
||||
.limit(1);
|
||||
if (!existing) return candidate;
|
||||
candidate = `${desired.slice(0, 50)}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uniqueEventSlug(
|
||||
desired: string,
|
||||
excludeEventId?: string,
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
let candidate = desired.slice(0, 60) || "event";
|
||||
let suffix = 2;
|
||||
while (true) {
|
||||
const [existing] = await db
|
||||
.select({ id: events.id })
|
||||
.from(events)
|
||||
.where(eq(events.slug, candidate))
|
||||
.limit(1);
|
||||
if (!existing || existing.id === excludeEventId) return candidate;
|
||||
candidate = `${desired.slice(0, 50)}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGroupForUser(
|
||||
input: { userId: string; name: string },
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const slug = await uniqueGroupSlug(slugify(input.name), db);
|
||||
const [group] = await db
|
||||
.insert(groups)
|
||||
.values({
|
||||
name: input.name,
|
||||
slug,
|
||||
createdByUserId: input.userId,
|
||||
})
|
||||
.returning();
|
||||
if (!group) throw new Error("Could not create group");
|
||||
await db.insert(groupMemberships).values({
|
||||
groupId: group.id,
|
||||
userId: input.userId,
|
||||
role: "owner",
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
export async function ensureGroupMembership(
|
||||
input: { groupId: string; userId: string; role: GroupRole },
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(groupMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(groupMemberships.groupId, input.groupId),
|
||||
eq(groupMemberships.userId, input.userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) return existing;
|
||||
const [created] = await db
|
||||
.insert(groupMemberships)
|
||||
.values(input)
|
||||
.returning();
|
||||
return created!;
|
||||
}
|
||||
|
||||
export async function ensureEventMembership(
|
||||
input: { eventId: string; userId: string; role: EventRole; groupId: string },
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
await ensureGroupMembership(
|
||||
{ groupId: input.groupId, userId: input.userId, role: "member" },
|
||||
db,
|
||||
);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(eventMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(eventMemberships.eventId, input.eventId),
|
||||
eq(eventMemberships.userId, input.userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
if (existing.role === input.role) return existing;
|
||||
const [updated] = await db
|
||||
.update(eventMemberships)
|
||||
.set({ role: input.role, updatedAt: new Date() })
|
||||
.where(eq(eventMemberships.id, existing.id))
|
||||
.returning();
|
||||
return updated!;
|
||||
}
|
||||
const [created] = await db
|
||||
.insert(eventMemberships)
|
||||
.values({
|
||||
eventId: input.eventId,
|
||||
userId: input.userId,
|
||||
role: input.role,
|
||||
})
|
||||
.returning();
|
||||
return created!;
|
||||
}
|
||||
|
||||
export async function countEventOwners(eventId: string, db: Database = getDb()) {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(eventMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(eventMemberships.eventId, eventId),
|
||||
eq(eventMemberships.role, "owner"),
|
||||
),
|
||||
);
|
||||
return Number(row?.value ?? 0);
|
||||
}
|
||||
|
||||
export async function countGroupOwners(groupId: string, db: Database = getDb()) {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(groupMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(groupMemberships.groupId, groupId),
|
||||
eq(groupMemberships.role, "owner"),
|
||||
),
|
||||
);
|
||||
return Number(row?.value ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
EVENT_ROLES,
|
||||
PLATFORM_ROLES,
|
||||
eventPermissionsForRole,
|
||||
platformEventPermissions,
|
||||
} from "../server/permissions";
|
||||
import { EVENT_PERMISSIONS } from "../server/permissions";
|
||||
|
||||
describe("event roles", () => {
|
||||
test("owners can delete events and grant owner", () => {
|
||||
const permissions = eventPermissionsForRole("owner");
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.EVENT_DELETE);
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER);
|
||||
});
|
||||
|
||||
test("managers cannot delete events or grant owner", () => {
|
||||
const permissions = eventPermissionsForRole("manager");
|
||||
expect(permissions).not.toContain(EVENT_PERMISSIONS.EVENT_DELETE);
|
||||
expect(permissions).not.toContain(EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER);
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
||||
});
|
||||
|
||||
test("moderators cannot see private photos", () => {
|
||||
const permissions = eventPermissionsForRole("moderator");
|
||||
expect(permissions).not.toContain(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.PHOTOS_MODERATE);
|
||||
});
|
||||
|
||||
test("viewers are read-only", () => {
|
||||
const permissions = eventPermissionsForRole("viewer");
|
||||
expect(permissions).not.toContain(EVENT_PERMISSIONS.PHOTOS_MODERATE);
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.PHOTOS_READ);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform roles", () => {
|
||||
test("admin cannot grant super admin", () => {
|
||||
expect(PLATFORM_ROLES.admin.permissions).not.toContain(
|
||||
"platform.roles.grant_super_admin",
|
||||
);
|
||||
});
|
||||
|
||||
test("platform viewer gets read-only event access", () => {
|
||||
const permissions = platformEventPermissions("viewer");
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.PHOTOS_READ);
|
||||
expect(permissions).not.toContain(EVENT_PERMISSIONS.PHOTOS_MODERATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("role catalogs exist", () => {
|
||||
test("four event roles", () => {
|
||||
expect(Object.keys(EVENT_ROLES)).toEqual([
|
||||
"owner",
|
||||
"manager",
|
||||
"moderator",
|
||||
"viewer",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { EventRole, PhotoVisibility, PlatformRole } from "@album/contracts";
|
||||
|
||||
export const EVENT_PERMISSIONS = {
|
||||
OVERVIEW_READ: "overview.read",
|
||||
PHOTOS_READ: "photos.read",
|
||||
PHOTOS_MODERATE: "photos.moderate",
|
||||
PHOTOS_PRIVATE_READ: "photos.private.read",
|
||||
PHOTOS_DELETE: "photos.delete",
|
||||
SETTINGS_READ: "settings.read",
|
||||
SETTINGS_MANAGE: "settings.manage",
|
||||
PEOPLE_READ: "people.read",
|
||||
PEOPLE_MANAGE: "people.manage",
|
||||
PEOPLE_GRANT_OWNER: "people.grant_owner",
|
||||
EVENT_DELETE: "event.delete",
|
||||
NOTES_READ: "notes.read",
|
||||
GALLERY_RELEASE: "gallery.release",
|
||||
AUDIT_READ: "audit.read",
|
||||
} as const;
|
||||
|
||||
export type EventPermission =
|
||||
(typeof EVENT_PERMISSIONS)[keyof typeof EVENT_PERMISSIONS];
|
||||
|
||||
export const GROUP_PERMISSIONS = {
|
||||
GROUP_READ: "group.read",
|
||||
GROUP_MANAGE: "group.manage",
|
||||
EVENTS_CREATE: "events.create",
|
||||
PEOPLE_MANAGE: "group.people.manage",
|
||||
} as const;
|
||||
|
||||
export type GroupPermission =
|
||||
(typeof GROUP_PERMISSIONS)[keyof typeof GROUP_PERMISSIONS];
|
||||
|
||||
export const PLATFORM_PERMISSIONS = {
|
||||
SETTINGS_MANAGE: "platform.settings.manage",
|
||||
USERS_MANAGE: "platform.users.manage",
|
||||
GRANT_SUPER_ADMIN: "platform.roles.grant_super_admin",
|
||||
GRANT_ADMIN: "platform.roles.grant_admin",
|
||||
EVENTS_READ: "platform.events.read",
|
||||
EVENTS_MODERATE: "platform.events.moderate",
|
||||
ENTITLEMENTS_MANAGE: "platform.entitlements.manage",
|
||||
AUDIT_READ: "platform.audit.read",
|
||||
GROUPS_ACCESS: "platform.groups.access",
|
||||
} as const;
|
||||
|
||||
export type PlatformPermission =
|
||||
(typeof PLATFORM_PERMISSIONS)[keyof typeof PLATFORM_PERMISSIONS];
|
||||
|
||||
const allEventPermissions = Object.values(EVENT_PERMISSIONS);
|
||||
|
||||
export const EVENT_ROLES = {
|
||||
owner: {
|
||||
label: "Owner",
|
||||
description: "Full event access, including people and deletion.",
|
||||
permissions: allEventPermissions,
|
||||
},
|
||||
manager: {
|
||||
label: "Manager",
|
||||
description: "Run the event without transferring ownership or deleting it.",
|
||||
permissions: allEventPermissions.filter(
|
||||
(permission) =>
|
||||
permission !== EVENT_PERMISSIONS.EVENT_DELETE &&
|
||||
permission !== EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER,
|
||||
),
|
||||
},
|
||||
moderator: {
|
||||
label: "Moderator",
|
||||
description: "Review photos and notes. Cannot change settings or people.",
|
||||
permissions: [
|
||||
EVENT_PERMISSIONS.OVERVIEW_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_MODERATE,
|
||||
EVENT_PERMISSIONS.PHOTOS_DELETE,
|
||||
EVENT_PERMISSIONS.NOTES_READ,
|
||||
],
|
||||
},
|
||||
viewer: {
|
||||
label: "Viewer",
|
||||
description: "See event photos, including hidden ones.",
|
||||
permissions: [
|
||||
EVENT_PERMISSIONS.OVERVIEW_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_READ,
|
||||
EVENT_PERMISSIONS.NOTES_READ,
|
||||
],
|
||||
},
|
||||
} as const satisfies Record<
|
||||
EventRole,
|
||||
{
|
||||
label: string;
|
||||
description: string;
|
||||
permissions: readonly EventPermission[];
|
||||
}
|
||||
>;
|
||||
|
||||
export const GROUP_ROLES = {
|
||||
owner: {
|
||||
label: "Owner",
|
||||
permissions: Object.values(GROUP_PERMISSIONS),
|
||||
},
|
||||
member: {
|
||||
label: "Member",
|
||||
permissions: [GROUP_PERMISSIONS.GROUP_READ],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const allPlatformPermissions = Object.values(PLATFORM_PERMISSIONS);
|
||||
|
||||
export const PLATFORM_ROLES = {
|
||||
super_admin: {
|
||||
label: "Super admin",
|
||||
permissions: allPlatformPermissions,
|
||||
},
|
||||
admin: {
|
||||
label: "Admin",
|
||||
permissions: allPlatformPermissions.filter(
|
||||
(permission) => permission !== PLATFORM_PERMISSIONS.GRANT_SUPER_ADMIN,
|
||||
),
|
||||
},
|
||||
moderator: {
|
||||
label: "Platform moderator",
|
||||
permissions: [
|
||||
PLATFORM_PERMISSIONS.EVENTS_READ,
|
||||
PLATFORM_PERMISSIONS.EVENTS_MODERATE,
|
||||
PLATFORM_PERMISSIONS.AUDIT_READ,
|
||||
PLATFORM_PERMISSIONS.GROUPS_ACCESS,
|
||||
],
|
||||
},
|
||||
viewer: {
|
||||
label: "Platform viewer",
|
||||
permissions: [
|
||||
PLATFORM_PERMISSIONS.EVENTS_READ,
|
||||
PLATFORM_PERMISSIONS.AUDIT_READ,
|
||||
PLATFORM_PERMISSIONS.GROUPS_ACCESS,
|
||||
],
|
||||
},
|
||||
} as const satisfies Record<
|
||||
PlatformRole,
|
||||
{
|
||||
label: string;
|
||||
permissions: readonly PlatformPermission[];
|
||||
}
|
||||
>;
|
||||
|
||||
export function eventPermissionsForRole(role: EventRole): EventPermission[] {
|
||||
return [...EVENT_ROLES[role].permissions];
|
||||
}
|
||||
|
||||
export function groupPermissionsForRole(
|
||||
role: "owner" | "member",
|
||||
): GroupPermission[] {
|
||||
return [...GROUP_ROLES[role].permissions];
|
||||
}
|
||||
|
||||
export function platformPermissionsForRole(
|
||||
role: PlatformRole,
|
||||
): PlatformPermission[] {
|
||||
return [...PLATFORM_ROLES[role].permissions];
|
||||
}
|
||||
|
||||
export function platformEventPermissions(
|
||||
role: PlatformRole,
|
||||
): EventPermission[] {
|
||||
if (role === "super_admin" || role === "admin") return allEventPermissions;
|
||||
if (role === "moderator") {
|
||||
return [
|
||||
EVENT_PERMISSIONS.OVERVIEW_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_MODERATE,
|
||||
EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_DELETE,
|
||||
EVENT_PERMISSIONS.NOTES_READ,
|
||||
EVENT_PERMISSIONS.AUDIT_READ,
|
||||
];
|
||||
}
|
||||
return [
|
||||
EVENT_PERMISSIONS.OVERVIEW_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_READ,
|
||||
EVENT_PERMISSIONS.NOTES_READ,
|
||||
EVENT_PERMISSIONS.AUDIT_READ,
|
||||
EVENT_PERMISSIONS.SETTINGS_READ,
|
||||
EVENT_PERMISSIONS.PEOPLE_READ,
|
||||
];
|
||||
}
|
||||
|
||||
export function platformGroupPermissions(
|
||||
role: PlatformRole,
|
||||
): GroupPermission[] {
|
||||
if (role === "super_admin" || role === "admin") {
|
||||
return Object.values(GROUP_PERMISSIONS);
|
||||
}
|
||||
if (role === "moderator" || role === "viewer") {
|
||||
return [GROUP_PERMISSIONS.GROUP_READ];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const PUBLIC_VISIBILITIES: PhotoVisibility[] = ["public"];
|
||||
export const MEMBER_VISIBILITIES: PhotoVisibility[] = [
|
||||
"pending",
|
||||
"public",
|
||||
"hidden",
|
||||
"rejected",
|
||||
];
|
||||
export const PRIVATE_VISIBILITIES: PhotoVisibility[] = [
|
||||
"pending",
|
||||
"public",
|
||||
"hidden",
|
||||
"private",
|
||||
"rejected",
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user