Initial commit of Vellum, an event photo product for guest uploads, host moderation, and original-quality galleries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-07 19:36:14 -04:00
co-authored by Cursor
commit 27e2f196eb
149 changed files with 13847 additions and 0 deletions
@@ -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>
);
}