1
0
Fork 0

feat(awards): add ribbon design studio, review queue and submission UI

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-11 03:25:59 -04:00
parent ac822f259c
commit 6169bf6ce6
15 changed files with 1142 additions and 302 deletions

View file

@ -0,0 +1,251 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import hasRoles from "@/utils/access-control/hasRoles";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { validateRibbonDesign } from "@/lib/awards/ribbonValidation";
import type { RibbonSubmission } from "@/payload-types";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
const MAX_PENDING_RIBBON_SUBMISSIONS = 3;
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
async function fetchSubmission(
payload: Awaited<ReturnType<typeof getPayload>>,
id: number,
): Promise<RibbonSubmission | null> {
try {
return await payload.findByID({
collection: "ribbon-submissions",
id,
depth: 0,
overrideAccess: true,
});
} catch {
return null;
}
}
export async function submitRibbonDesign(input: {
name: string;
description: string;
design: Record<string, unknown>;
}): Promise<ActionResult<{ id: number }>> {
try {
const { payload, user } = await authenticate();
const trimmedName = input.name.trim();
if (trimmedName.length === 0) {
return { success: false, error: "A ribbon name is required." };
}
if (trimmedName.length > 60) {
return { success: false, error: "Ribbon names must be 60 characters or fewer." };
}
const trimmedDescription = input.description.trim();
if (trimmedDescription.length === 0) {
return { success: false, error: "A ribbon description is required." };
}
if (trimmedDescription.length > 300) {
return { success: false, error: "Ribbon descriptions must be 300 characters or fewer." };
}
const validation = validateRibbonDesign(input.design);
if (!validation.ok) {
return { success: false, error: validation.error };
}
const pendingCount = await payload.count({
collection: "ribbon-submissions",
where: {
and: [
{ user: { equals: user.id } },
{ status: { equals: "pending" } },
],
},
overrideAccess: true,
});
if (pendingCount.totalDocs >= MAX_PENDING_RIBBON_SUBMISSIONS) {
return { success: false, error: "You already have 3 pending ribbon submissions. Wait for them to be reviewed first." };
}
const submission = await payload.create({
collection: "ribbon-submissions",
data: {
name: trimmedName,
description: trimmedDescription,
design: input.design,
user: user.id,
status: "pending",
},
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.AwardRibbonSubmitted,
message: `${user.displayName ?? user.username} submitted the ribbon design "${trimmedName}"`,
actor: user.id,
targetCollection: "ribbon-submissions",
targetId: submission.id,
data: {
submissionId: submission.id,
submitterId: user.id,
name: trimmedName,
},
});
return { success: true, data: { id: submission.id } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function withdrawRibbonSubmission(submissionId: number): Promise<ActionResult<{ ok: true }>> {
try {
const { payload, user } = await authenticate();
const submission = await fetchSubmission(payload, submissionId);
if (!submission) {
return { success: false, error: "Submission not found." };
}
if (submission.user !== user.id) {
return { success: false, error: "You can only withdraw your own submissions." };
}
if (submission.status !== "pending") {
return { success: false, error: "Only pending submissions can be withdrawn." };
}
await payload.delete({
collection: "ribbon-submissions",
id: submissionId,
overrideAccess: true,
});
return { success: true, data: { ok: true } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function reviewRibbonSubmission(
submissionId: number,
decision: "approved" | "rejected",
opts?: {
awardXp?: number;
grantorTypes?: ("division" | "squad" | "team" | "special")[];
recipientScope?: "same_command" | "anyone";
maxGrantsPerGrantor?: number | null;
finalName?: string;
reviewNote?: string;
},
): Promise<ActionResult<{ id: number; status: string }>> {
try {
const { payload, user } = await authenticate();
if (!hasRoles(["admin", "developer"], user)) {
return { success: false, error: "Only admins and developers can review ribbon submissions." };
}
const submission = await fetchSubmission(payload, submissionId);
if (!submission) {
return { success: false, error: "Submission not found." };
}
if (submission.status !== "pending") {
return { success: false, error: "This submission has already been reviewed." };
}
if (decision === "approved") {
if (opts?.awardXp === undefined || !Number.isInteger(opts.awardXp) || opts.awardXp < 1) {
return { success: false, error: "XP must be at least 1." };
}
if (!opts?.grantorTypes || !Array.isArray(opts.grantorTypes) || opts.grantorTypes.length === 0) {
return { success: false, error: "Choose at least one grantor type." };
}
const validGrantorTypes = ["division", "squad", "team", "special"];
for (const grantorType of opts.grantorTypes) {
if (!validGrantorTypes.includes(grantorType)) {
return { success: false, error: "Invalid grantor type." };
}
}
if (opts.recipientScope !== undefined && opts.recipientScope !== "same_command" && opts.recipientScope !== "anyone") {
return { success: false, error: "Whitelist scoping is not available yet." };
}
}
const updateData: Record<string, unknown> = {
status: decision,
reviewNote: opts?.reviewNote ?? null,
};
if (decision === "approved") {
updateData.awardXp = opts?.awardXp;
updateData.grantorTypes = opts?.grantorTypes;
updateData.recipientScope = opts?.recipientScope ?? "same_command";
updateData.maxGrantsPerGrantor = opts?.maxGrantsPerGrantor ?? null;
if (opts?.finalName && opts.finalName.trim() !== "" && opts.finalName.trim() !== submission.name) {
updateData.name = opts.finalName.trim();
}
}
await payload.update({
collection: "ribbon-submissions",
id: submissionId,
data: updateData,
overrideAccess: true,
depth: 0,
});
// Check if the afterChange hook reverted the status back to pending due to award creation failure
const updatedSubmission = await payload.findByID({
collection: "ribbon-submissions",
id: submissionId,
depth: 0,
overrideAccess: true,
});
if (decision === "approved" && updatedSubmission.status === "pending") {
return { success: false, error: "Approval failed while creating the award. The submission is back to pending; try again." };
}
return { success: true, data: { id: submissionId, status: decision } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}

View file

@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import config from "@payload-config";
import { MySubmissions } from "@/components/frontend/awards/MySubmissions";
import { RibbonStudio } from "@/components/frontend/awards/RibbonStudio";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
export const metadata: Metadata = {
title: "Ribbon Studio: Polaris Task Force",
description: "Design a custom ribbon and submit it for approval.",
};
export default async function RibbonDesignPage() {
const headers = await nextHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!isPayloadUser(user)) {
redirect(`/login?returnTo=${encodeURIComponent("/awards/design")}`);
}
const submissions = await payload.find({
collection: "ribbon-submissions",
where: { user: { equals: user.id } },
sort: "-createdAt",
depth: 1,
});
return (
<div className="flex min-w-0 flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold">Ribbon Studio</h1>
<p className="text-sm text-muted-foreground">
Design a custom ribbon and submit it for approval. Approved ribbons become awardable.
</p>
</div>
<div className="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(20rem,0.65fr)]">
<RibbonStudio />
<MySubmissions submissions={submissions.docs} />
</div>
</div>
);
}

View file

@ -1,9 +1,14 @@
import config from "@payload-config";
import { getPayload } from "payload";
import Link from "next/link";
import { headers as nextHeaders } from "next/headers";
import type { Award, Media } from "@/payload-types";
import { AwardWithGrantButton } from "@/components/frontend/account/AwardWithGrantButton";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Button } from "@/components/ui/button";
import { MedalIcon } from "lucide-react";
import hasRoles from "@/utils/access-control/hasRoles";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
export const metadata = {
title: "Awards",
@ -17,7 +22,12 @@ const TYPE_LABEL: Record<"ribbon" | "medal", string> = {
};
export default async function AwardsPage() {
const payload = await getPayload({ config });
const headers = await nextHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const user = isPayloadUser(authUser) ? authUser : null;
const canReview = hasRoles(["admin", "developer"], user);
const res = await payload.find({
collection: "awards",
@ -30,6 +40,16 @@ export default async function AwardsPage() {
overrideAccess: true,
});
const pendingCount = canReview
? (
await payload.count({
collection: "ribbon-submissions",
where: { status: { equals: "pending" } },
overrideAccess: true,
})
).totalDocs
: 0;
const awards: Award[] = res.docs;
const ribbons = awards.filter((a) => a.type === "ribbon");
const medals = awards.filter((a) => a.type === "medal");
@ -40,16 +60,28 @@ export default async function AwardsPage() {
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<MedalIcon className="size-4 text-muted-foreground" />
<h1 className="text-2xl font-bold tracking-tight uppercase">Awards</h1>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<MedalIcon className="size-4 text-muted-foreground" />
<h1 className="text-2xl font-bold tracking-tight uppercase">Awards</h1>
</div>
<p className="text-sm text-muted-foreground max-w-2xl">
A reference of every medal and ribbon that can be earned by a member of Polaris Task
Force. Review the requirements for each award and its artwork. Commendations are granted
on the fly and are not catalogued here.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild size="sm">
<Link href="/awards/design">Design a ribbon</Link>
</Button>
{canReview && (
<Button asChild size="sm" variant="outline">
<Link href="/awards/review">Review queue ({pendingCount})</Link>
</Button>
)}
</div>
<p className="text-sm text-muted-foreground max-w-2xl">
A reference of every medal and ribbon that can be earned by a member of Polaris Task
Force. Review the requirements for each award and its artwork. Commendations are granted
on the fly and are not catalogued here.
</p>
</div>
{awards.length === 0 ? (

View file

@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import config from "@payload-config";
import { ReviewQueue } from "@/components/frontend/awards/ReviewQueue";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import hasRoles from "@/utils/access-control/hasRoles";
export const metadata: Metadata = {
title: "Ribbon Review",
description: "Review submitted ribbon designs for Polaris Task Force awards.",
};
export default async function RibbonReviewPage() {
const headers = await nextHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!isPayloadUser(user)) {
redirect(`/login?returnTo=${encodeURIComponent("/awards/review")}`);
}
if (!hasRoles(["admin", "developer"], user)) {
redirect("/awards");
}
const submissions = await payload.find({
collection: "ribbon-submissions",
where: { status: { equals: "pending" } },
sort: "-createdAt",
depth: 1,
overrideAccess: true,
});
return (
<div className="flex min-w-0 flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold">Ribbon Review</h1>
<p className="text-sm text-muted-foreground">
Approve or reject submitted ribbon designs. Approval settings decide XP and who can grant
the award.
</p>
</div>
<ReviewQueue submissions={submissions.docs} />
</div>
);
}

View file

@ -3,293 +3,22 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useField } from "@payloadcms/ui";
// ─── Types ──────────────────────────────────────────────────────────
import { DEFAULT_RIBBON } from "@/components/awards/ribbonDesign/constants";
import { RibbonPreview, StripeEditor, DeviceEditor } from "@/components/awards/ribbonDesign/parts";
import type { RibbonDesign, Stripe, Device } from "@/components/awards/ribbonDesign/types";
type DeviceType = "star" | "oakLeaf" | "arrowUp" | "campaignClasp";
type Device = {
id: string;
type: DeviceType;
position: number;
positionY: number;
};
type Stripe = {
color: string;
width: number;
};
export type RibbonDesign = {
stripes: Stripe[];
devices: Device[];
mirrored: boolean;
};
// ─── Constants ──────────────────────────────────────────────────────
export const DEFAULT_RIBBON: RibbonDesign = {
stripes: [
{ color: "#b91c1c", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#1e3a5f", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#b91c1c", width: 30 },
],
devices: [],
mirrored: false,
};
const MILITARY_COLORS: { name: string; hex: string }[] = [
{ name: "Scarlet", hex: "#b91c1c" },
{ name: "Crimson", hex: "#991b1b" },
{ name: "Red", hex: "#dc2626" },
{ name: "Dark Blue", hex: "#1e3a5f" },
{ name: "Navy", hex: "#1e293b" },
{ name: "Air Force Blue", hex: "#5b8fa8" },
{ name: "Sky Blue", hex: "#38bdf8" },
{ name: "Gold", hex: "#ca8a04" },
{ name: "Yellow", hex: "#eab308" },
{ name: "Forest Green", hex: "#166534" },
{ name: "Green", hex: "#16a34a" },
{ name: "Olive", hex: "#4d7c0f" },
{ name: "Maroon", hex: "#581c87" },
{ name: "Purple", hex: "#7e22ce" },
{ name: "Orange", hex: "#ea580c" },
{ name: "Brown", hex: "#78350f" },
{ name: "Tan / Khaki", hex: "#d4a056" },
{ name: "White", hex: "#f5f5f4" },
{ name: "Silver / Gray", hex: "#9ca3af" },
{ name: "Black", hex: "#18181b" },
];
export const RIBBON_DEVICE_INFO: Record<DeviceType, { label: string; symbol: string }> = {
star: { label: "Star", symbol: "\u2605" },
oakLeaf: { label: "Oak Leaf", symbol: "\u{1F333}" },
arrowUp: { label: "Arrow", symbol: "\u2191" },
campaignClasp: { label: "Clasp", symbol: "\u2015" },
};
export const RIBBON_CONSTANTS = {
RIBBON_ASPECT: 3.667,
RIBBON_WIDTH: 366,
PREVIEW_HEIGHT: 80,
};
const { RIBBON_ASPECT, RIBBON_WIDTH, PREVIEW_HEIGHT } = RIBBON_CONSTANTS;
// ─── Preview ────────────────────────────────────────────────────────
function RibbonPreview({ design }: { design: RibbonDesign }) {
const { stripes, devices, mirrored } = design;
if (stripes.length === 0) return null;
const displayStripes = mirrored ? [...stripes, ...stripes.slice(0, -1).reverse()] : stripes;
const totalWeight = displayStripes.reduce((sum, s) => sum + s.width, 0);
const viewWidth = RIBBON_WIDTH;
const viewHeight = PREVIEW_HEIGHT;
return (
<svg
viewBox={`0 0 ${viewWidth} ${viewHeight}`}
className="w-full h-auto rounded border border-zinc-700 bg-zinc-950"
style={{ maxHeight: 140 }}
>
<rect x={0} y={0} width={viewWidth} height={viewHeight} rx={2} fill="#18181b" />
{(() => {
let x = 0;
return displayStripes.map((stripe, i) => {
const w = (stripe.width / totalWeight) * viewWidth;
const el = <rect key={i} x={x} y={0} width={w} height={viewHeight} fill={stripe.color} />;
x += w;
return el;
});
})()}
{devices.map((device) => {
const info = RIBBON_DEVICE_INFO[device.type];
const cx = (device.position / 100) * viewWidth;
const cy = (device.positionY / 100) * viewHeight;
const fontSize = Math.min(viewHeight * 0.45, 24);
return (
<text
key={device.id}
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fill="#facc15"
fontSize={fontSize}
fontFamily="serif"
style={{ pointerEvents: "none" }}
>
{info.symbol}
</text>
);
})}
</svg>
);
}
// ─── Stripe Editor ──────────────────────────────────────────────────
function StripeEditor({
stripes,
onChange,
}: {
stripes: Stripe[];
onChange: (s: Stripe[]) => void;
}) {
const totalWeight = stripes.reduce((sum, s) => sum + s.width, 0);
const addStripe = () => onChange([...stripes, { color: "#f5f5f4", width: 10 }]);
const removeStripe = (idx: number) => onChange(stripes.filter((_, i) => i !== idx));
const updateStripe = (idx: number, patch: Partial<Stripe>) =>
onChange(stripes.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Stripes</span>
<button
type="button"
onClick={addStripe}
className="text-[11px] px-2 py-0.5 rounded bg-emerald-950 border border-emerald-700 text-emerald-200 hover:bg-emerald-900 transition-colors cursor-pointer"
>
+ Add Stripe
</button>
</div>
{stripes.map((stripe, idx) => (
<div key={idx} className="flex items-center gap-2 bg-zinc-900 rounded px-2 py-1.5">
<input
type="color"
value={stripe.color}
onChange={(e) => updateStripe(idx, { color: e.target.value })}
className="size-7 rounded cursor-pointer bg-transparent border-0 p-0"
/>
<input
type="text"
value={stripe.color}
onChange={(e) => updateStripe(idx, { color: e.target.value })}
className="text-[11px] font-mono bg-zinc-800 border border-zinc-700 rounded px-2 py-1 w-20 text-zinc-300"
/>
<div className="flex items-center gap-1.5 flex-1">
<label className="text-[10px] text-zinc-500 shrink-0">%</label>
<input
type="range"
min={1}
max={100}
value={stripe.width}
onChange={(e) => updateStripe(idx, { width: Number(e.target.value) })}
className="flex-1 h-1 accent-zinc-400"
/>
<span className="text-[10px] text-zinc-500 font-mono w-8 text-right">
{totalWeight > 0 ? Math.round((stripe.width / totalWeight) * 100) : 0}%
</span>
</div>
<div
className="size-5 rounded shrink-0 border border-zinc-700"
style={{ backgroundColor: stripe.color }}
/>
<button
type="button"
onClick={() => removeStripe(idx)}
className="text-zinc-500 hover:text-red-400 text-sm cursor-pointer"
>
\u00D7
</button>
</div>
))}
</div>
);
}
// ─── Device Editor ──────────────────────────────────────────────────
function DeviceEditor({
devices,
onChange,
}: {
devices: Device[];
onChange: (d: Device[]) => void;
}) {
const addDevice = (type: DeviceType) =>
onChange([...devices, { id: crypto.randomUUID(), type, position: 50, positionY: 50 }]);
const removeDevice = (id: string) => onChange(devices.filter((d) => d.id !== id));
const updateDevice = (id: string, patch: Partial<Device>) =>
onChange(devices.map((d) => (d.id === id ? { ...d, ...patch } : d)));
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Devices</span>
<div className="flex flex-wrap gap-1.5">
{(Object.keys(RIBBON_DEVICE_INFO) as DeviceType[]).map((type) => {
const info = RIBBON_DEVICE_INFO[type];
return (
<button
key={type}
type="button"
onClick={() => addDevice(type)}
className="text-[11px] px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-zinc-300 hover:bg-zinc-700 transition-colors cursor-pointer"
>
{info.symbol} {info.label}
</button>
);
})}
</div>
{devices.length > 0 && (
<div className="flex flex-col gap-1.5">
{devices.map((device) => {
const info = RIBBON_DEVICE_INFO[device.type];
return (
<div
key={device.id}
className="flex items-center gap-2 bg-zinc-900 rounded px-2 py-1.5"
>
<span className="text-sm">{info.symbol}</span>
<span className="text-[11px] text-zinc-400">{info.label}</span>
<div className="flex items-center gap-1.5 flex-1">
<label className="text-[10px] text-zinc-500 shrink-0">X</label>
<input
type="range"
min={0}
max={100}
value={device.position}
onChange={(e) => updateDevice(device.id, { position: Number(e.target.value) })}
className="flex-1 h-1 accent-zinc-400"
/>
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
{device.position}
</span>
</div>
<div className="flex items-center gap-1.5 flex-1">
<label className="text-[10px] text-zinc-500 shrink-0">Y</label>
<input
type="range"
min={0}
max={100}
value={device.positionY}
onChange={(e) => updateDevice(device.id, { positionY: Number(e.target.value) })}
className="flex-1 h-1 accent-zinc-400"
/>
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
{device.positionY}
</span>
</div>
<button
type="button"
onClick={() => removeDevice(device.id)}
className="text-zinc-500 hover:text-red-400 text-sm cursor-pointer"
>
\u00D7
</button>
</div>
);
})}
</div>
)}
</div>
);
}
export type {
RibbonDesign,
Stripe,
Device,
DeviceType,
} from "@/components/awards/ribbonDesign/types";
export {
DEFAULT_RIBBON,
MILITARY_COLORS,
RIBBON_CONSTANTS,
RIBBON_DEVICE_INFO,
} from "@/components/awards/ribbonDesign/constants";
// ─── Main Designer ──────────────────────────────────────────────────

View file

@ -2,7 +2,7 @@ import { Award, Media } from "@/payload-types";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { cn } from "@/lib/utils";
const getExperiencePointsClass = (experiencePoints: number) => {

View file

@ -2,7 +2,7 @@ import { Award, Media } from "@/payload-types";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { AwardIcon, LockIcon } from "lucide-react";

View file

@ -10,7 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { RibbonViewer } from "./RibbonViewer";
import { GrantAwardDialog } from "./GrantAwardDialog";
import { MedalIcon, RibbonIcon, ScrollTextIcon } from "lucide-react";

View file

@ -1,4 +1,4 @@
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Award } from "@/payload-types";

View file

@ -3,9 +3,9 @@
import {
RIBBON_DEVICE_INFO,
RIBBON_CONSTANTS,
RibbonDesign,
DEFAULT_RIBBON,
} from "@/components/admin/awards/RibbonDesigner";
} from "@/components/awards/ribbonDesign/constants";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { useState } from "react";
import { cn } from "@/lib/utils";

View file

@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { withdrawRibbonSubmission } from "@/app/(frontend)/awards/design/actions";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { RibbonSubmission } from "@/payload-types";
interface MySubmissionsProps {
readonly submissions: RibbonSubmission[];
}
const statusClasses: Record<RibbonSubmission["status"], string> = {
pending: "border-amber-500/40 text-amber-400",
approved: "border-emerald-500/40 text-emerald-400",
rejected: "border-red-500/40 text-red-400",
};
function formatDate(value: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export function MySubmissions({ submissions }: MySubmissionsProps) {
const router = useRouter();
const [withdrawingId, setWithdrawingId] = useState<number | null>(null);
async function handleWithdraw(submissionId: number) {
if (!window.confirm("Withdraw this submission?")) return;
setWithdrawingId(submissionId);
try {
const result = await withdrawRibbonSubmission(submissionId);
if (!result.success) {
toast.error(result.error ?? "Could not withdraw this submission.");
return;
}
toast.success("Ribbon submission withdrawn");
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not withdraw this submission.");
} finally {
setWithdrawingId(null);
}
}
return (
<Card>
<CardHeader className="gap-1">
<CardTitle>My submissions</CardTitle>
<CardDescription>Track the ribbons you have sent for review.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{submissions.length === 0 ? (
<p className="text-sm text-muted-foreground">
No submissions yet. Design your first ribbon above.
</p>
) : (
submissions.map((submission) => (
<div
key={submission.id}
className="flex flex-col gap-3 rounded-md border border-border p-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="w-28 shrink-0 [&>svg]:w-full">
<RibbonViewer ribbonDesign={submission.design as RibbonDesign} showRibbonOnly />
</div>
<div className="flex min-w-0 flex-col gap-1">
<p className="truncate font-medium">{submission.name}</p>
<time dateTime={submission.createdAt} className="text-xs text-muted-foreground">
{formatDate(submission.createdAt)}
</time>
</div>
</div>
<Badge
variant="outline"
className={cn("shrink-0 capitalize", statusClasses[submission.status])}
>
{submission.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{submission.description}</p>
{submission.reviewNote && (
<p className="rounded-md border border-border bg-background/50 p-3 text-sm">
Reviewer note: {submission.reviewNote}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
{submission.status === "approved" && (
<Button asChild variant="outline" size="sm">
<Link href="/awards">View award</Link>
</Button>
)}
{submission.status === "pending" && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleWithdraw(submission.id)}
disabled={withdrawingId !== null}
>
{withdrawingId === submission.id ? "Withdrawing..." : "Withdraw"}
</Button>
)}
</div>
</div>
))
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,110 @@
"use client";
import { useEffect, useId, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { reviewRibbonSubmission } from "@/app/(frontend)/awards/design/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { RibbonSubmission } from "@/payload-types";
interface RejectRibbonDialogProps {
readonly submission: RibbonSubmission;
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
}
export function RejectRibbonDialog({ submission, open, onOpenChange }: RejectRibbonDialogProps) {
const router = useRouter();
const noteId = useId();
const [reviewNote, setReviewNote] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => {
if (!open) return;
setReviewNote("");
setPending(false);
}, [open]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
const result = await reviewRibbonSubmission(submission.id, "rejected", {
reviewNote: reviewNote.trim() || undefined,
});
if (!result.success) {
toast.error(result.error ?? "Could not reject this ribbon.");
return;
}
toast.success("Ribbon rejected");
onOpenChange(false);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not reject this ribbon.");
} finally {
setPending(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!pending) onOpenChange(nextOpen);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reject ribbon</DialogTitle>
<DialogDescription>
Add optional feedback for the submitter before rejecting &quot;{submission.name}&quot;.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<Label htmlFor={noteId}>Review note</Label>
<Textarea
id={noteId}
value={reviewNote}
onChange={(event) => setReviewNote(event.target.value)}
rows={4}
disabled={pending}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button
type="submit"
variant="outline"
className="text-destructive hover:text-destructive"
disabled={pending}
>
{pending ? "Rejecting..." : "Reject ribbon"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { CheckIcon, XIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import type { RibbonSubmission } from "@/payload-types";
import { ApproveRibbonDialog } from "./RibbonReviewDialogs";
import { RejectRibbonDialog } from "./RejectRibbonDialog";
interface ReviewQueueProps {
readonly submissions: RibbonSubmission[];
}
function submitterName(submission: RibbonSubmission): string {
if (typeof submission.user === "object" && submission.user !== null) {
return submission.user.displayName || submission.user.username;
}
return `User #${submission.user}`;
}
function SubmissionCard({ submission }: { readonly submission: RibbonSubmission }) {
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const submittedDate = new Date(submission.createdAt).toLocaleDateString(undefined, {
dateStyle: "medium",
});
return (
<Card className="flex h-full flex-col overflow-hidden">
<CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="break-words">{submission.name}</CardTitle>
<CardDescription>
Submitted by {submitterName(submission)} on {submittedDate}
</CardDescription>
</div>
<Badge variant="outline" className="shrink-0 border-amber-500/40 text-amber-400">
Pending
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="rounded-lg border bg-muted/20 p-4">
<RibbonViewer ribbonDesign={submission.design as RibbonDesign} />
</div>
<p className="text-sm text-muted-foreground">{submission.description}</p>
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<Button onClick={() => setApproveOpen(true)}>
<CheckIcon />
Approve
</Button>
<Button
variant="outline"
className={cn("text-destructive hover:text-destructive")}
onClick={() => setRejectOpen(true)}
>
<XIcon />
Reject
</Button>
</CardFooter>
<ApproveRibbonDialog
submission={submission}
open={approveOpen}
onOpenChange={setApproveOpen}
/>
<RejectRibbonDialog submission={submission} open={rejectOpen} onOpenChange={setRejectOpen} />
</Card>
);
}
export function ReviewQueue({ submissions }: ReviewQueueProps) {
if (submissions.length === 0) {
return (
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">No pending submissions.</p>
</CardContent>
</Card>
);
}
return (
<div className="grid min-w-0 gap-4 xl:grid-cols-2">
{submissions.map((submission) => (
<SubmissionCard key={submission.id} submission={submission} />
))}
</div>
);
}

View file

@ -0,0 +1,244 @@
"use client";
import { useEffect, useId, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { reviewRibbonSubmission } from "@/app/(frontend)/awards/design/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { RibbonSubmission } from "@/payload-types";
type GrantorType = "division" | "squad" | "team" | "special";
type RecipientScope = "same_command" | "anyone";
const GRANTOR_OPTIONS: readonly { readonly value: GrantorType; readonly label: string }[] = [
{ value: "division", label: "Division Leader" },
{ value: "squad", label: "Squad Leader" },
{ value: "team", label: "Team Leader" },
{ value: "special", label: "Special Assignment Leader" },
];
interface ReviewDialogProps {
readonly submission: RibbonSubmission;
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
}
export function ApproveRibbonDialog({ submission, open, onOpenChange }: ReviewDialogProps) {
const router = useRouter();
const fieldId = useId();
const [awardXp, setAwardXp] = useState("1");
const [grantorTypes, setGrantorTypes] = useState<GrantorType[]>(["division"]);
const [recipientScope, setRecipientScope] = useState<RecipientScope>("same_command");
const [maxGrantsPerGrantor, setMaxGrantsPerGrantor] = useState("");
const [finalName, setFinalName] = useState(submission.name);
const [reviewNote, setReviewNote] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => {
if (!open) return;
setAwardXp("1");
setGrantorTypes(["division"]);
setRecipientScope("same_command");
setMaxGrantsPerGrantor("");
setFinalName(submission.name);
setReviewNote("");
setPending(false);
}, [open, submission.name]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const xp = Number(awardXp);
const trimmedName = finalName.trim();
const trimmedNote = reviewNote.trim();
const maxGrantsValue = maxGrantsPerGrantor.trim() ? Number(maxGrantsPerGrantor) : null;
if (!Number.isInteger(xp) || xp < 1) {
toast.error("Experience Points must be at least 1.");
return;
}
if (grantorTypes.length === 0) {
toast.error("Choose at least one grantor type.");
return;
}
if (maxGrantsValue !== null && (!Number.isInteger(maxGrantsValue) || maxGrantsValue < 1)) {
toast.error("Max grants per grantor must be a whole number of at least 1.");
return;
}
if (!trimmedName) {
toast.error("Enter an award name.");
return;
}
setPending(true);
try {
const result = await reviewRibbonSubmission(submission.id, "approved", {
awardXp: xp,
grantorTypes,
recipientScope,
maxGrantsPerGrantor: maxGrantsValue || null,
finalName: trimmedName !== submission.name ? trimmedName : undefined,
reviewNote: trimmedNote || undefined,
});
if (!result.success) {
toast.error(result.error ?? "Could not approve this ribbon.");
return;
}
toast.success("Ribbon approved and award created");
onOpenChange(false);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not approve this ribbon.");
} finally {
setPending(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!pending) onOpenChange(nextOpen);
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Approve ribbon</DialogTitle>
<DialogDescription>
Set the award rules before creating this ribbon as a grantable award.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${fieldId}-xp`}>Experience Points</Label>
<Input
id={`${fieldId}-xp`}
type="number"
min={1}
step={1}
required
value={awardXp}
onChange={(event) => setAwardXp(event.target.value)}
disabled={pending}
/>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium">Grantor types</legend>
{GRANTOR_OPTIONS.map((option) => {
const optionId = `${fieldId}-${option.value}`;
return (
<div key={option.value} className="flex items-center gap-2">
<Checkbox
id={optionId}
checked={grantorTypes.includes(option.value)}
onCheckedChange={(checked) => {
setGrantorTypes((current) =>
checked === true
? [...current, option.value]
: current.filter((type) => type !== option.value),
);
}}
disabled={pending}
/>
<Label htmlFor={optionId}>{option.label}</Label>
</div>
);
})}
</fieldset>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${fieldId}-scope`}>Recipient scope</Label>
<Select
value={recipientScope}
onValueChange={(value) => {
if (value === "same_command" || value === "anyone") setRecipientScope(value);
}}
disabled={pending}
>
<SelectTrigger id={`${fieldId}-scope`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="same_command">Own command tree</SelectItem>
<SelectItem value="anyone">Anyone (unit-wide)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${fieldId}-limit`}>Max grants per grantor (empty = unlimited)</Label>
<Input
id={`${fieldId}-limit`}
type="number"
min={1}
step={1}
value={maxGrantsPerGrantor}
onChange={(event) => setMaxGrantsPerGrantor(event.target.value)}
disabled={pending}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${fieldId}-name`}>Award name</Label>
<Input
id={`${fieldId}-name`}
value={finalName}
maxLength={60}
required
onChange={(event) => setFinalName(event.target.value)}
disabled={pending}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${fieldId}-note`}>Review note</Label>
<Textarea
id={`${fieldId}-note`}
value={reviewNote}
onChange={(event) => setReviewNote(event.target.value)}
rows={3}
disabled={pending}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" disabled={pending || grantorTypes.length === 0}>
{pending ? "Approving..." : "Approve ribbon"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,142 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { submitRibbonDesign } from "@/app/(frontend)/awards/design/actions";
import { DEFAULT_RIBBON } from "@/components/awards/ribbonDesign/constants";
import { DeviceEditor, RibbonPreview, StripeEditor } from "@/components/awards/ribbonDesign/parts";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { validateRibbonDesign } from "@/lib/awards/ribbonValidation";
export function RibbonStudio() {
const router = useRouter();
const [design, setDesign] = useState<RibbonDesign>(DEFAULT_RIBBON);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const validation = validateRibbonDesign(design);
const submitDisabled =
submitting || !validation.ok || name.trim().length === 0 || description.trim().length === 0;
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!validation.ok) {
toast.error(validation.error);
return;
}
setSubmitting(true);
try {
const result = await submitRibbonDesign({ name, description, design });
if (!result.success) {
toast.error(result.error ?? "Could not submit this ribbon.");
return;
}
toast.success("Ribbon submitted for review");
setDesign(DEFAULT_RIBBON);
setName("");
setDescription("");
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not submit this ribbon.");
} finally {
setSubmitting(false);
}
}
return (
<Card>
<CardHeader className="gap-1">
<CardTitle>Design your ribbon</CardTitle>
<CardDescription>
Build the stripe pattern, place devices, and send the finished design to the award team.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-6" onSubmit={handleSubmit}>
<div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(16rem,0.75fr)]">
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="ribbon-name">Name</Label>
<Input
id="ribbon-name"
value={name}
maxLength={60}
onChange={(event) => setName(event.target.value)}
placeholder="For example, Operation Northern Star"
/>
<p className="text-right text-xs text-muted-foreground">{name.length}/60</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ribbon-description">Description</Label>
<Textarea
id="ribbon-description"
value={description}
maxLength={300}
rows={5}
onChange={(event) => setDescription(event.target.value)}
placeholder="What should this ribbon recognize?"
/>
<p className="text-right text-xs text-muted-foreground">{description.length}/300</p>
</div>
<div className="flex items-start gap-3 rounded-md border border-border p-3">
<Checkbox
id="ribbon-mirrored"
checked={design.mirrored}
onCheckedChange={(checked) =>
setDesign((current) => ({ ...current, mirrored: checked === true }))
}
/>
<div className="flex flex-col gap-1">
<Label htmlFor="ribbon-mirrored">Mirror stripe pattern</Label>
<p className="text-xs text-muted-foreground">
Reflect the pattern across the center for a balanced ribbon.
</p>
</div>
</div>
</div>
<div className="flex min-w-0 flex-col gap-5">
<div className="flex flex-col gap-2">
<Label>Preview</Label>
<RibbonPreview design={design} />
</div>
<StripeEditor
stripes={design.stripes}
onChange={(stripes) => setDesign((current) => ({ ...current, stripes }))}
/>
<DeviceEditor
devices={design.devices}
onChange={(devices) => setDesign((current) => ({ ...current, devices }))}
/>
</div>
</div>
{!validation.ok && <p className="text-sm text-destructive">{validation.error}</p>}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Submissions are reviewed before they can be awarded.
</p>
<Button type="submit" disabled={submitDisabled}>
{submitting ? "Submitting..." : "Submit for review"}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}