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 config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import type { Award, Media } from "@/payload-types";
|
import type { Award, Media } from "@/payload-types";
|
||||||
import { AwardWithGrantButton } from "@/components/frontend/account/AwardWithGrantButton";
|
import { AwardWithGrantButton } from "@/components/frontend/account/AwardWithGrantButton";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { MedalIcon } from "lucide-react";
|
import { MedalIcon } from "lucide-react";
|
||||||
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Awards",
|
title: "Awards",
|
||||||
|
|
@ -17,7 +22,12 @@ const TYPE_LABEL: Record<"ribbon" | "medal", string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function AwardsPage() {
|
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({
|
const res = await payload.find({
|
||||||
collection: "awards",
|
collection: "awards",
|
||||||
|
|
@ -30,6 +40,16 @@ export default async function AwardsPage() {
|
||||||
overrideAccess: true,
|
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 awards: Award[] = res.docs;
|
||||||
const ribbons = awards.filter((a) => a.type === "ribbon");
|
const ribbons = awards.filter((a) => a.type === "ribbon");
|
||||||
const medals = awards.filter((a) => a.type === "medal");
|
const medals = awards.filter((a) => a.type === "medal");
|
||||||
|
|
@ -40,6 +60,7 @@ export default async function AwardsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
<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 flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MedalIcon className="size-4 text-muted-foreground" />
|
<MedalIcon className="size-4 text-muted-foreground" />
|
||||||
|
|
@ -51,6 +72,17 @@ export default async function AwardsPage() {
|
||||||
on the fly and are not catalogued here.
|
on the fly and are not catalogued here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? (
|
{awards.length === 0 ? (
|
||||||
<Item variant="muted">
|
<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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useField } from "@payloadcms/ui";
|
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";
|
export type {
|
||||||
|
RibbonDesign,
|
||||||
type Device = {
|
Stripe,
|
||||||
id: string;
|
Device,
|
||||||
type: DeviceType;
|
DeviceType,
|
||||||
position: number;
|
} from "@/components/awards/ribbonDesign/types";
|
||||||
positionY: number;
|
export {
|
||||||
};
|
DEFAULT_RIBBON,
|
||||||
|
MILITARY_COLORS,
|
||||||
type Stripe = {
|
RIBBON_CONSTANTS,
|
||||||
color: string;
|
RIBBON_DEVICE_INFO,
|
||||||
width: number;
|
} from "@/components/awards/ribbonDesign/constants";
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Designer ──────────────────────────────────────────────────
|
// ─── Main Designer ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Award, Media } from "@/payload-types";
|
||||||
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const getExperiencePointsClass = (experiencePoints: number) => {
|
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 { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AwardIcon, LockIcon } from "lucide-react";
|
import { AwardIcon, LockIcon } from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
|
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
|
||||||
import { RibbonViewer } from "./RibbonViewer";
|
import { RibbonViewer } from "./RibbonViewer";
|
||||||
import { GrantAwardDialog } from "./GrantAwardDialog";
|
import { GrantAwardDialog } from "./GrantAwardDialog";
|
||||||
import { MedalIcon, RibbonIcon, ScrollTextIcon } from "lucide-react";
|
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 { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Award } from "@/payload-types";
|
import { Award } from "@/payload-types";
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import {
|
import {
|
||||||
RIBBON_DEVICE_INFO,
|
RIBBON_DEVICE_INFO,
|
||||||
RIBBON_CONSTANTS,
|
RIBBON_CONSTANTS,
|
||||||
RibbonDesign,
|
|
||||||
DEFAULT_RIBBON,
|
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 { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
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