Promotion service lib, ceiling field on Ranks, permission gate, migration, and GM promote button with tests. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
130 lines
No EOL
4.2 KiB
TypeScript
130 lines
No EOL
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { promoteMember } from "@/app/(frontend)/profile/[username]/actions";
|
|
import { ArrowUpIcon, Loader2Icon, MedalIcon } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
interface PromoteButtonProps {
|
|
username: string;
|
|
currentRankName: string;
|
|
targetRanks: readonly { id: number; name: string }[];
|
|
}
|
|
|
|
/**
|
|
* Profile-page section that lets a viewer with promotion authority promote the
|
|
* profile owner. Authority and the allowed target ranks are computed server-side
|
|
* by the page; this component only provides the entry point and submits the
|
|
* chosen rank id.
|
|
*/
|
|
export function PromoteButton({ username, currentRankName, targetRanks }: PromoteButtonProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [rankId, setRankId] = useState<number | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const router = useRouter();
|
|
|
|
const selectedRankId = rankId ?? targetRanks[0]?.id ?? null;
|
|
const selectedRank = targetRanks.find((r) => r.id === selectedRankId);
|
|
|
|
async function handlePromote() {
|
|
if (selectedRankId == null || submitting) return;
|
|
setSubmitting(true);
|
|
setError(null);
|
|
const res = await promoteMember(username, selectedRankId);
|
|
if (res.success) {
|
|
toast.success("Promotion applied", {
|
|
description: `${username} promoted to ${selectedRank?.name ?? "new rank"}.`,
|
|
});
|
|
setOpen(false);
|
|
router.refresh();
|
|
} else {
|
|
setError(res.error ?? "Failed to promote member.");
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<MedalIcon className="size-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Promotion
|
|
</h2>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setRankId(null);
|
|
setError(null);
|
|
setOpen(true);
|
|
}}
|
|
>
|
|
<ArrowUpIcon data-icon="inline-start" />
|
|
Promote
|
|
</Button>
|
|
</div>
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(o) => {
|
|
if (!submitting) setOpen(o);
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Promote {username}</DialogTitle>
|
|
<DialogDescription>
|
|
Current rank: {currentRankName}. Select the new rank, then confirm.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-2">
|
|
<Select
|
|
value={selectedRankId != null ? String(selectedRankId) : undefined}
|
|
onValueChange={(v) => setRankId(Number(v))}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a rank" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{targetRanks.map((r) => (
|
|
<SelectItem key={r.id} value={String(r.id)}>
|
|
{r.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handlePromote} disabled={selectedRankId == null || submitting}>
|
|
{submitting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
|
Confirm promotion
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</section>
|
|
);
|
|
} |