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:
parent
ac822f259c
commit
6169bf6ce6
15 changed files with 1142 additions and 302 deletions
251
src/app/(frontend)/awards/design/actions.ts
Normal file
251
src/app/(frontend)/awards/design/actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
48
src/app/(frontend)/awards/design/page.tsx
Normal file
48
src/app/(frontend)/awards/design/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,6 +60,7 @@ export default async function AwardsPage() {
|
|||
|
||||
return (
|
||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
||||
<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" />
|
||||
|
|
@ -51,6 +72,17 @@ export default async function AwardsPage() {
|
|||
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>
|
||||
</div>
|
||||
|
||||
{awards.length === 0 ? (
|
||||
<Item variant="muted">
|
||||
|
|
|
|||
51
src/app/(frontend)/awards/review/page.tsx
Normal file
51
src/app/(frontend)/awards/review/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
128
src/components/frontend/awards/MySubmissions.tsx
Normal file
128
src/components/frontend/awards/MySubmissions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/frontend/awards/RejectRibbonDialog.tsx
Normal file
110
src/components/frontend/awards/RejectRibbonDialog.tsx
Normal 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 "{submission.name}".
|
||||
</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>
|
||||
);
|
||||
}
|
||||
105
src/components/frontend/awards/ReviewQueue.tsx
Normal file
105
src/components/frontend/awards/ReviewQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
src/components/frontend/awards/RibbonReviewDialogs.tsx
Normal file
244
src/components/frontend/awards/RibbonReviewDialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/components/frontend/awards/RibbonStudio.tsx
Normal file
142
src/components/frontend/awards/RibbonStudio.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue