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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user