1
0
Fork 0

Compare commits

...

5 commits

Author SHA1 Message Date
90195286c3 docs: mark helpdesk ticket editing as done, add wiki system idea 2026-09-02 16:14:16 -04:00
a1bdf4ffb1 feat(awards): add cleanupDuplicateAwards utility and CLI tool skeleton 2026-09-02 16:14:12 -04:00
2e1fbf8006 feat(awards): support bulk recipient selection in award grant flow
Refactors grantAward to accept an array of recipientUserIds, adds
duplicate-award and eligibility checks per recipient, and returns a
per-recipient result summary. GrantAwardDialog gains a third step
showing grant results when some recipients fail. RecipientPicker is
rewritten to support multi-select with chip-based selected state and
a maxSelections cap.
2026-09-02 16:14:07 -04:00
d952a33ee7 feat(awards): render backgroundImage as faded backdrop on medal cards 2026-09-02 16:14:03 -04:00
d8867a643a feat(awards): add backgroundImage field and migration for medal cards 2026-09-02 16:14:00 -04:00
17 changed files with 21288 additions and 227 deletions

View file

@ -6,7 +6,8 @@
- [x] **Ribbon overview and listing page** - There should be a page that lists all of the medals and ribbons that could be granted to a user (excluding commendations, which could arise on the fly and are not set in stone.) This would allow users to review the requirements for earning a specific medal or ribbon, and get to see the art of that award as well, if any.
- [x] **Assignment transfer request system** - Allow users to request transfers to new assignments (i.e. transfer from infantry to aviation, medical to infantry, medical to aviation, infantry to intel, logistics to infantry, etc., covering all cases). The leader of the user's current assignment and the leader of the user's requested assignment should be notified of the request via app and via bot, and should have the option to "approve" or "reject with reason" in either interface. Approvals are only collected from assignments that actually have a leader: if the user's current assignment has no leader (e.g. it lost its leader), only the requested assignment's leader needs to approve — and vice versa if the requested assignment is leaderless. If neither assignment has a leader, skip the leaders entirely and send the request directly to me (or a superuser) for a final call. Once all required approvals are in, the assignment is automatically transferred and updated everywhere. If any required approval is rejected, the rejection reason is sent to the other leader (if one exists) and to the individual making the request, and the individual is given an option to appeal via the app or the bot — this applies even when only one approval was required. If they appeal, all remaining leaders are bypassed and the request goes directly to me (or a superuser) who can then make the final call.
- [ ] **Scoped admin UI pages** - The admin UI is fantastic, but it needs to be scoped so that users can only see and interact with certain pages even if they have read permissions for any/all pages. For example, if a user has read permissions for the Users collection, they can't just see the admin page for it - they need something like "admin:users:manage" or something like that (based on whatever format fits our current system best.) So long as they have BOTH the read and manage permissions, they can see the admin page. Otherwise, it's just hidden from them entirely.
- [ ] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase.
- [x] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase.
- [ ] **User-maintained wiki** - There really should be a wiki system (similar to MediaWiki or Fandom) where members of the unit can contribute information they have gathered about the campaign, its world and lore, characters, plot points, media, etc., with revision history, collaborative editing, template and tag support, wikilinks, and similar functionality. This is a BIG lift, but also an essential component, ESPECIALLY for our intelligence division members. While everyone should be allowed to contribute and make edits, intelligence division members should have moderator powers over this wiki, with the capability to lockdown pages, restore versions, etc.
- [ ] **"Personnel" navigation group** - A "Personnel" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc.
- [ ] **Respawn tickets based on attendance** - I want to track respawn tickets as a "global variable" that is automatically incremented based on reported attendance for a mission compared against real attendance checked via the webapp<->Arma sync system. For every person who marks that they will attend and DOES attend, 1 respawn ticket is awarded. For every person who marks they MIGHT attend and DOES attend, 0.5 respawn tickets will be awarded. For every person who marks that they will NOT attend and does attend, 0.25 respawn tickets will be awarded. All respawn tickets are accumulated and applied to the next operation.

View file

@ -12,7 +12,8 @@ import {
getGrantorAssignments,
isRecipientEligible,
} from "@/lib/awards/grant";
import type { Profile } from "@/payload-types";
import { cleanupDuplicateAwards, type CleanupResult } from "@/lib/awards/index";
import type { Assignment, Profile } from "@/payload-types";
interface ActionResult<T = undefined> {
success: boolean;
@ -37,14 +38,24 @@ async function authenticate() {
return { payload, user };
}
export async function grantAward(input: {
export interface GrantResult {
recipientUserId: number;
success: boolean;
error?: string;
}
export async function grantAward(input: {
recipientUserIds: number[];
awardId: number;
reason?: string;
}): Promise<ActionResult> {
}): Promise<ActionResult<{ results: GrantResult[]; granted: number; failed: number }>> {
try {
const { payload, user } = await authenticate();
if (input.recipientUserIds.length === 0) {
return { success: false, error: "No recipients selected." };
}
let award;
try {
award = await payload.findByID({
@ -60,11 +71,17 @@ export async function grantAward(input: {
let grantorProfile: Profile | null = null;
const superuser = await isSuperuser(payload, user);
if (award.type === "medal") {
if (!superuser) {
return { success: false, error: "Only superusers can grant medals." };
}
} else if (!superuser) {
const isMedal = award.type === "medal";
if (isMedal && !superuser) {
return { success: false, error: "Only superusers can grant medals." };
}
let grantorAssignmentId: number | undefined;
let scope: "same_command" | "whitelist" | "anyone" | null | undefined;
let whitelist: (number | Assignment)[] | null | undefined;
let remaining: number | null = null;
if (!superuser && !isMedal) {
const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
if (allowedTypes.length === 0) {
return { success: false, error: "This award cannot be granted by leaders." };
@ -78,7 +95,9 @@ export async function grantAward(input: {
if (grantorAssignments.length === 0) {
return { success: false, error: "You are not authorized to grant this award." };
}
const grantorAssignmentId = grantorAssignments[0].id;
grantorAssignmentId = grantorAssignments[0].id;
scope = award.grantConfig?.recipientScope ?? "same_command";
whitelist = award.grantConfig?.eligibleRecipientAssignments;
const maxGrants = award.grantConfig?.maxGrantsPerGrantor ?? null;
if (maxGrants) {
@ -87,29 +106,14 @@ export async function grantAward(input: {
return { success: false, error: "You do not have a profile." };
}
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
if (issued >= maxGrants) {
remaining = maxGrants - issued;
if (remaining <= 0) {
return {
success: false,
error: "You have reached the maximum number of grants for this award.",
};
}
}
const eligible = await isRecipientEligible(
payload,
input.recipientUserId,
award.grantConfig?.recipientScope ?? "same_command",
grantorAssignmentId,
award.grantConfig?.eligibleRecipientAssignments,
);
if (!eligible) {
return { success: false, error: "Recipient is not eligible for this award." };
}
}
const recipientProfile = await findUserProfile(payload, input.recipientUserId);
if (!recipientProfile) {
return { success: false, error: "Recipient does not have a profile." };
}
if (!grantorProfile) {
@ -119,37 +123,115 @@ export async function grantAward(input: {
return { success: false, error: "You do not have a profile." };
}
const progression = recipientProfile.progression;
const currentAwards = progression?.awards ?? [];
const results: GrantResult[] = [];
let granted = 0;
let processed = 0;
await payload.update({
collection: "profiles",
id: recipientProfile.id,
data: {
progression: {
...progression,
awards: [
...currentAwards,
{
award: award.id,
awardedBy: grantorProfile.id,
awardedAt: new Date().toISOString(),
awardReason: input.reason ?? null,
},
],
for (const recipientUserId of input.recipientUserIds) {
if (remaining !== null && processed >= remaining) break;
if (grantorAssignmentId !== undefined) {
const eligible = await isRecipientEligible(
payload,
recipientUserId,
scope,
grantorAssignmentId,
whitelist,
);
if (!eligible) {
results.push({
recipientUserId,
success: false,
error: "Recipient is not eligible for this award.",
});
processed += 1;
continue;
}
}
const recipientProfile = await findUserProfile(payload, recipientUserId);
if (!recipientProfile) {
results.push({
recipientUserId,
success: false,
error: "Recipient does not have a profile.",
});
processed += 1;
continue;
}
const progression = recipientProfile.progression;
const currentAwards = progression?.awards ?? [];
const alreadyHasAward = currentAwards.some((e) => {
const id = typeof e.award === "number" ? e.award : e.award?.id;
return id === award.id;
});
if (alreadyHasAward) {
results.push({
recipientUserId,
success: false,
error: "Recipient already has this award.",
});
processed += 1;
continue;
}
await payload.update({
collection: "profiles",
id: recipientProfile.id,
data: {
progression: {
...progression,
awards: [
...currentAwards,
{
award: award.id,
awardedBy: grantorProfile.id,
awardedAt: new Date().toISOString(),
awardReason: input.reason ?? null,
},
],
},
},
},
overrideAccess: true,
});
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.AwardGrant,
message: `Granted "${award.name}" to recipient profile #${recipientProfile.id}`,
actor: user.id,
data: { awardId: award.id, grantorProfileId: grantorProfile.id },
});
await emitGameEvent(payload, {
type: EventTypes.AwardGrant,
message: `Granted "${award.name}" to recipient profile #${recipientProfile.id}`,
actor: user.id,
data: { awardId: award.id, grantorProfileId: grantorProfile.id },
});
return { success: true };
results.push({ recipientUserId, success: true });
granted += 1;
processed += 1;
}
return {
success: true,
data: { results, granted, failed: results.length - granted },
};
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
}
export async function cleanupDuplicateAwardsAction(): Promise<
ActionResult<CleanupResult>
> {
try {
const { payload, user } = await authenticate();
const superuser = await isSuperuser(payload, user);
if (!superuser) {
return { success: false, error: "Only superusers can run award cleanup." };
}
const result = await cleanupDuplicateAwards(payload);
return { success: true, data: result };
} catch (e) {
return {
success: false,

View file

@ -73,8 +73,17 @@ export default async function AwardsPage() {
{section.items.map((award) => {
const awardImage =
typeof award.image === "object" && award.image ? (award.image as Media) : null;
const backgroundImage =
typeof award.backgroundImage === "object" && award.backgroundImage
? (award.backgroundImage as Media)
: null;
return (
<AwardWithGrantButton key={award.id} award={award} awardImage={awardImage} />
<AwardWithGrantButton
key={award.id}
award={award}
awardImage={awardImage}
backgroundImage={backgroundImage}
/>
);
})}
</div>

View file

@ -623,7 +623,18 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
.map((award) => {
const awardImage =
award.image && typeof award.image === "object" ? (award.image as Media) : null;
return <AwardCard key={award.id} awardImage={awardImage} award={award} />;
const backgroundImage =
award.backgroundImage && typeof award.backgroundImage === "object"
? (award.backgroundImage as Media)
: null;
return (
<AwardCard
key={award.id}
awardImage={awardImage}
backgroundImage={backgroundImage}
award={award}
/>
);
})}
</div>
) : (

View file

@ -406,38 +406,69 @@
.medal-card::after {
content: "";
z-index: 2;
background: radial-gradient(circle, transparent, rgb(253 224 71 / 0.1));
position: absolute;
inset: 0;
animation: pulse 5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
pointer-events: none;
}
.medal-card > .medal-card-bg,
.medal-card > .medal-card-scrim {
position: absolute !important;
inset: 0;
z-index: 0 !important;
}
.medal-card > .medal-card-bg {
width: 100%;
height: 100%;
border-radius: inherit;
}
.medal-card > .medal-card-scrim {
border-radius: inherit;
background-color: rgb(0 0 0 / 0.42);
box-shadow: inset 0 0 2.25rem rgb(0 0 0 / 0.38);
}
.medal-card.medal-card-overflow {
overflow: visible;
}
.award-xp-high {
background: linear-gradient(110deg, rgb(245 158 11), rgb(254 249 195), rgb(250 204 21), rgb(245 158 11));
background-size: 200% auto;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
filter: drop-shadow(0 0 0.3rem rgb(250 204 21 / 0.35));
animation: award-xp-shimmer 2.5s linear infinite;
background: linear-gradient(
110deg,
rgb(245 158 11),
rgb(254 249 195),
rgb(250 204 21),
rgb(245 158 11)
);
background-size: 200% auto;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
filter: drop-shadow(0 0 0.3rem rgb(250 204 21 / 0.35));
animation: award-xp-shimmer 2.5s linear infinite;
}
@keyframes award-xp-shimmer {
to {
background-position: 200% center;
}
to {
background-position: 200% center;
}
}
@media (prefers-reduced-motion: reduce) {
.award-xp-high {
animation: none;
}
.award-xp-high {
animation: none;
}
}
.commendation-card {
align-self: start;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
align-self: start;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
}
/* Scrollbars — styled to match the dark frontend theme */

View file

@ -74,6 +74,16 @@ export const Awards: CollectionConfig = {
condition: (data) => data?.type === "ribbon",
},
},
{
name: "backgroundImage",
type: "upload",
relationTo: "media",
admin: {
description:
"A background image for the award card. Displayed as a faded backdrop behind the card content.",
condition: (data) => data?.type === "medal",
},
},
{
name: "medalDesign",
type: "json",

View file

@ -13,7 +13,11 @@ const getExperiencePointsClass = (experiencePoints: number) => {
return "text-muted-foreground";
};
export default function AwardCard(props: { awardImage: Media | null; award: Award }) {
export default function AwardCard(props: {
awardImage: Media | null;
award: Award;
backgroundImage?: Media | null;
}) {
const isCommendation = props.award.type === "commendation" || !props.award.type;
const commendationColor =
isCommendation &&
@ -22,12 +26,14 @@ export default function AwardCard(props: { awardImage: Media | null; award: Awar
? props.award.commendationColor
: null;
const bgUrl = props.backgroundImage?.url;
return (
<Item
variant="outline"
size={isCommendation ? "sm" : "default"}
className={cn("text-center", {
"medal-card": props.award.type === "medal",
className={cn("relative overflow-hidden text-center", {
"medal-card medal-card-overflow": props.award.type === "medal",
"commendation-card self-start h-fit": isCommendation,
})}
style={
@ -39,7 +45,21 @@ export default function AwardCard(props: { awardImage: Media | null; award: Awar
: undefined
}
>
<ItemHeader className="flex flex-col items-center">
{bgUrl && (
<>
<img
src={bgUrl}
alt=""
aria-hidden
className="medal-card-bg absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
/>
<div
aria-hidden
className="medal-card-scrim absolute inset-0 pointer-events-none bg-gradient-to-b from-background/70 via-background/60 to-background/80"
/>
</>
)}
<ItemHeader className="relative z-10 flex flex-col items-center">
{props.award.ribbonDesign && (
<RibbonViewer
ribbonDesign={props.award.ribbonDesign as RibbonDesign}
@ -69,7 +89,7 @@ export default function AwardCard(props: { awardImage: Media | null; award: Awar
</div>
</div>
</ItemHeader>
<ItemContent>
<ItemContent className="relative z-10">
<ItemDescription
className={cn("text-center", isCommendation ? "line-clamp-none text-xs" : "line-clamp-3")}
>

View file

@ -20,6 +20,7 @@ import { AwardIcon, LockIcon } from "lucide-react";
interface AwardListingCardProps {
award: Award;
awardImage?: Media | null;
backgroundImage?: Media | null;
showGrantButton?: boolean;
onGrantClick?: () => void;
canGrant?: boolean | null;
@ -29,6 +30,7 @@ interface AwardListingCardProps {
export function AwardListingCard({
award,
awardImage,
backgroundImage,
showGrantButton = false,
onGrantClick,
canGrant,
@ -42,16 +44,31 @@ export function AwardListingCard({
typeof award.ribbonDesign === "object" &&
Array.isArray((award.ribbonDesign as RibbonDesign).stripes);
const hasArt = hasRibbonArt || Boolean(awardImage?.url);
const bgUrl = backgroundImage?.url;
return (
<Item
variant="outline"
className={cn(
"min-w-0 h-full flex flex-col flex-nowrap text-center",
isMedal && "justify-center medal-card",
"relative overflow-hidden min-w-0 h-full flex flex-col flex-nowrap text-center",
isMedal && "justify-center medal-card medal-card-overflow",
)}
>
<ItemHeader className={cn("flex flex-col items-center gap-2", isMedal && "justify-center")}>
{bgUrl && (
<>
<img
src={bgUrl}
alt=""
aria-hidden
className="medal-card-bg absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
/>
<div
aria-hidden
className="medal-card-scrim absolute inset-0 pointer-events-none bg-gradient-to-b from-background/70 via-background/60 to-background/80"
/>
</>
)}
<ItemHeader className={cn("relative z-10 flex flex-col items-center gap-2", isMedal && "justify-center")}>
{hasArt && (
<div
className={cn(
@ -80,7 +97,7 @@ export function AwardListingCard({
</span>
</ItemHeader>
<ItemContent className="flex flex-col gap-2">
<ItemContent className="relative z-10 flex flex-col gap-2">
<ItemDescription className="min-w-0 break-words text-center text-sm leading-relaxed">
{award.description
? convertLexicalToPlaintext({ data: award.description })
@ -94,7 +111,7 @@ export function AwardListingCard({
</ItemContent>
{showGrantButton && (
<div className="mt-auto w-full">
<div className="relative z-10 mt-auto w-full">
{canGrant === false ? (
<Button
variant="outline"

View file

@ -11,9 +11,10 @@ import { getGrantStats } from "@/app/(frontend)/awards/grant-context";
interface AwardWithGrantButtonProps {
award: Award;
awardImage?: Media | null;
backgroundImage?: Media | null;
}
export function AwardWithGrantButton({ award, awardImage }: AwardWithGrantButtonProps) {
export function AwardWithGrantButton({ award, awardImage, backgroundImage }: AwardWithGrantButtonProps) {
const [open, setOpen] = useState(false);
const [canGrant, setCanGrant] = useState<boolean | null>(null);
const [remainingGrants, setRemainingGrants] = useState<number | null | undefined>(undefined);
@ -40,6 +41,7 @@ export function AwardWithGrantButton({ award, awardImage }: AwardWithGrantButton
<AwardListingCard
award={award}
awardImage={awardImage}
backgroundImage={backgroundImage}
showGrantButton
onGrantClick={() => setOpen(true)}
canGrant={canGrant}

View file

@ -14,10 +14,10 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { grantAward } from "@/app/(frontend)/awards/actions";
import { grantAward, type GrantResult } from "@/app/(frontend)/awards/actions";
import { getGrantStats, type GrantStats } from "@/app/(frontend)/awards/grant-context";
import { RecipientPicker, type RecipientSelection } from "./RecipientPicker";
import { Loader2Icon, TriangleAlertIcon } from "lucide-react";
import { CheckIcon, Loader2Icon, TriangleAlertIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
interface GrantAwardDialogProps {
@ -33,9 +33,10 @@ interface GrantAwardDialogProps {
}
/**
* Two-step grant flow: pick a recipient + reason, then confirm by typing the
* recipient's @username. Grant limits are pre-checked via getGrantStats so
* users see warnings before submitting.
* Two-step grant flow: pick recipient(s) + reason, then confirm by typing the
* award name. Grant limits are pre-checked via getGrantStats so selection is
* capped and users see warnings before submitting. Individual ineligible
* recipients are skipped and reported after the fact.
*/
export function GrantAwardDialog({
awardId,
@ -48,14 +49,19 @@ export function GrantAwardDialog({
onOpenChange,
onGranted,
}: GrantAwardDialogProps) {
const [step, setStep] = useState<1 | 2>(1);
const [recipient, setRecipient] = useState<RecipientSelection | null>(null);
const [step, setStep] = useState<1 | 2 | 3>(1);
const [recipients, setRecipients] = useState<RecipientSelection[]>([]);
const [reason, setReason] = useState("");
const [confirmText, setConfirmText] = useState("");
const [stats, setStats] = useState<GrantStats | null>(null);
const [statsError, setStatsError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<GrantResult[] | null>(null);
// Map a recipient id back to its username for result rendering.
const usernameOf = (id: number) =>
recipients.find((r) => r.id === id)?.username ?? String(id);
// Reset the flow each time the dialog opens; pre-fill a fixed recipient.
useEffect(() => {
@ -67,10 +73,11 @@ export function GrantAwardDialog({
setStatsError(null);
setError(null);
setSubmitting(false);
setRecipient(
setResults(null);
setRecipients(
!showRecipientPicker && recipientUserId != null && recipientUsername
? { id: recipientUserId, username: recipientUsername }
: null,
? [{ id: recipientUserId, username: recipientUsername }]
: [],
);
}, [open, showRecipientPicker, recipientUserId, recipientUsername]);
@ -88,26 +95,36 @@ export function GrantAwardDialog({
};
}, [open, awardId]);
const expectedConfirm = recipient ? `@${recipient.username}` : null;
const confirmMatches = expectedConfirm !== null && confirmText.trim() === expectedConfirm;
const expectedConfirm = awardName;
const confirmMatches = confirmText.trim() === expectedConfirm;
const limited = stats?.remainingGrants != null;
const maxSelections = limited ? stats?.remainingGrants ?? null : null;
const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2;
async function handleGrant() {
if (!recipient || !confirmMatches || submitting) return;
if (recipients.length === 0 || !confirmMatches || submitting) return;
setSubmitting(true);
setError(null);
const res = await grantAward({
recipientUserId: recipient.id,
recipientUserIds: recipients.map((r) => r.id),
awardId,
reason: reason.trim() ? reason.trim() : undefined,
});
if (res.success) {
toast.success("Award granted", {
description: `${awardName} granted to @${recipient.username}.`,
});
onGranted?.();
onOpenChange(false);
if (res.success && res.data) {
const { failed } = res.data;
if (failed === 0) {
toast.success("Award granted", {
description: `${awardName} granted to ${recipients.length} recipient${
recipients.length === 1 ? "" : "s"
}.`,
});
onGranted?.();
onOpenChange(false);
} else {
setResults(res.data.results);
setStep(3);
setSubmitting(false);
}
} else {
setError(res.error ?? "Failed to grant award.");
setSubmitting(false);
@ -125,10 +142,12 @@ export function GrantAwardDialog({
<DialogHeader>
<DialogTitle>Grant {awardName}</DialogTitle>
<DialogDescription>
{showRecipientPicker
? "Select a recipient, then confirm the grant."
: `Granting to @${recipientUsername}.`}
{stats && stats.canGrant && (
{step === 3
? "Grant summary."
: showRecipientPicker
? "Select recipients, then confirm the grant."
: `Granting to @${recipientUsername}.`}
{step !== 3 && stats && stats.canGrant && (
<span className="block mt-1 text-xs opacity-50">
{stats.remainingGrants === null
? "Unlimited grants available."
@ -146,11 +165,18 @@ export function GrantAwardDialog({
{showRecipientPicker ? (
<div className="flex flex-col gap-2">
<Label>Recipient</Label>
<Label>Recipients</Label>
<RecipientPicker
selectedUser={recipient}
onSelect={setRecipient}
onClear={() => setRecipient(null)}
selectedUsers={recipients}
onSelect={(u) =>
setRecipients((prev) =>
prev.some((r) => r.id === u.id) ? prev : [...prev, u],
)
}
onRemove={(id) =>
setRecipients((prev) => prev.filter((r) => r.id !== id))
}
maxSelections={maxSelections ?? undefined}
/>
</div>
) : (
@ -204,37 +230,53 @@ export function GrantAwardDialog({
</Button>
<Button
onClick={() => setStep(2)}
disabled={!recipient || (stats !== null && !stats.canGrant)}
disabled={recipients.length === 0 || (stats !== null && !stats.canGrant)}
>
Continue
</Button>
</DialogFooter>
</div>
) : (
) : step === 2 ? (
<div className="flex flex-col gap-4">
<Alert>
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>Confirm grant</AlertTitle>
<AlertDescription>
You are about to grant "{awardName}" to @{recipient?.username}. This action cannot
be undone.
You are about to grant "{awardName}" to{" "}
{recipients.length === 1
? `@${recipients[0].username}`
: `${recipients.length} recipients`}
. This action cannot be undone.
</AlertDescription>
</Alert>
{showRecipientPicker && recipients.length > 1 && (
<ul className="flex flex-wrap gap-1.5 text-sm">
{recipients.map((r) => (
<li
key={r.id}
className="rounded-full border bg-muted/40 px-2 py-0.5 text-xs"
>
@{r.username}
</li>
))}
</ul>
)}
{limited && (
<p className="text-sm text-muted-foreground">
{(stats?.remainingGrants ?? 0) <= 1
? "This will use your LAST grant for this award."
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - 1}.`}
{maxSelections != null && maxSelections <= recipients.length
? "This will use ALL of your remaining grants for this award."
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - recipients.length}.`}
</p>
)}
<div className="flex flex-col gap-2">
<Label>Type @{recipient?.username} to confirm</Label>
<Label>Type the award name to confirm</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={`@${recipient?.username}`}
placeholder={awardName}
autoFocus
/>
</div>
@ -256,6 +298,54 @@ export function GrantAwardDialog({
</Button>
</DialogFooter>
</div>
) : (
<div className="flex flex-col gap-4">
{results && (
<>
<Alert variant="destructive">
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>
{results.filter((r) => !r.success).length} of {results.length} grants could
not be completed
</AlertTitle>
<AlertDescription>
The following recipients were skipped; the rest were granted successfully.
</AlertDescription>
</Alert>
<ul className="flex flex-col gap-1.5 text-sm">
{results.map((r) => (
<li key={r.recipientUserId} className="flex items-start gap-2">
{r.success ? (
<CheckIcon className="mt-0.5 size-4 shrink-0 text-emerald-500" />
) : (
<XIcon className="mt-0.5 size-4 shrink-0 text-red-500" />
)}
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">
@{usernameOf(r.recipientUserId)}
</span>
{!r.success && r.error && (
<span className="text-xs text-muted-foreground">{r.error}</span>
)}
</span>
</li>
))}
</ul>
</>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
onGranted?.();
onOpenChange(false);
}}
>
Done
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>

View file

@ -15,9 +15,11 @@ export interface RecipientSelection {
}
interface RecipientPickerProps {
selectedUsers: RecipientSelection[];
onSelect: (user: RecipientSelection) => void;
selectedUser: RecipientSelection | null;
onClear: () => void;
onRemove: (id: number) => void;
maxSelections?: number;
disabled?: boolean;
}
interface SearchResult extends RecipientSelection {
@ -35,7 +37,13 @@ function initialsOf(username: string): string {
* `profile` relationship, so searching /api/users would not surface rank
* data) and enriches each hit with the user's primary assignment.
*/
export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPickerProps) {
export function RecipientPicker({
selectedUsers,
onSelect,
onRemove,
maxSelections,
disabled = false,
}: RecipientPickerProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
@ -136,119 +144,146 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
profileId: result.profileId,
});
setQuery("");
setOpen(false);
setActiveIndex(0);
}
if (selectedUser) {
return (
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
<Avatar className="size-7">
<AvatarFallback>{initialsOf(selectedUser.username)}</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">
{selectedUser.displayName || selectedUser.username}
</span>
<span className="text-sm text-muted-foreground">@{selectedUser.username}</span>
<button
type="button"
aria-label="Clear recipient"
onClick={onClear}
className="ml-auto rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<XIcon className="size-4" />
</button>
</div>
);
}
const atMax = maxSelections != null && selectedUsers.length >= maxSelections;
return (
<div ref={wrapperRef} className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (!open || results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const active = results[activeIndex];
if (active) select(active);
}
}}
placeholder="Search by username or display name..."
className="pl-8"
role="combobox"
aria-expanded={open}
aria-controls="recipient-picker-results"
/>
{open && query.trim().length > 0 && (
<div
id="recipient-picker-results"
role="listbox"
className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md"
>
{searching ? (
<div className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
Searching...
</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div>
) : (
results.map((result, i) => (
<div ref={wrapperRef} className="flex flex-col gap-2">
{selectedUsers.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedUsers.map((u) => (
<span
key={u.id}
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/40 py-1 pl-1 pr-1.5 text-sm"
>
<Avatar className="size-5">
<AvatarFallback className="text-[10px]">
{initialsOf(u.username)}
</AvatarFallback>
</Avatar>
<span className="max-w-[10rem] truncate font-medium">
{u.displayName || u.username}
</span>
<span className="truncate text-xs text-muted-foreground">@{u.username}</span>
<button
key={result.id}
type="button"
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => select(result)}
onMouseEnter={() => setActiveIndex(i)}
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left",
i === activeIndex && "bg-accent",
)}
aria-label={`Remove ${u.username}`}
disabled={disabled}
onClick={() => onRemove(u.id)}
className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
>
<Avatar className="size-8">
{result.insigniaUrl ? (
<AvatarImage src={result.insigniaUrl} alt="" />
) : (
<AvatarFallback>{initialsOf(result.username)}</AvatarFallback>
)}
</Avatar>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">
{result.displayName || result.username}
</span>
<span className="truncate text-xs text-muted-foreground">
@{result.username}
{result.rankName ? ` · ${result.rankName}` : ""}
</span>
</span>
{result.assignmentLabel && (
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{result.assignmentLabel}
</span>
)}
<XIcon className="size-3.5" />
</button>
))
)}
</span>
))}
</div>
)}
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
disabled={disabled || atMax}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (!open || results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const active = results[activeIndex];
if (active) select(active);
}
}}
placeholder={
atMax
? "Maximum recipients selected"
: "Search by username or display name..."
}
className="pl-8"
role="combobox"
aria-expanded={open}
aria-controls="recipient-picker-results"
/>
{open && query.trim().length > 0 && (
<div
id="recipient-picker-results"
role="listbox"
className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md"
>
{searching ? (
<div className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
Searching...
</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div>
) : (
results.map((result, i) => {
const alreadySelected = selectedUsers.some((u) => u.id === result.id);
return (
<button
key={result.id}
type="button"
role="option"
aria-selected={i === activeIndex}
disabled={alreadySelected || atMax}
onMouseDown={(e) => e.preventDefault()}
onClick={() => select(result)}
onMouseEnter={() => setActiveIndex(i)}
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left",
i === activeIndex && "bg-accent",
(alreadySelected || atMax) && "cursor-not-allowed opacity-50",
)}
>
<Avatar className="size-8">
{result.insigniaUrl ? (
<AvatarImage src={result.insigniaUrl} alt="" />
) : (
<AvatarFallback>{initialsOf(result.username)}</AvatarFallback>
)}
</Avatar>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">
{result.displayName || result.username}
</span>
<span className="truncate text-xs text-muted-foreground">
@{result.username}
{result.rankName ? ` · ${result.rankName}` : ""}
</span>
</span>
{result.assignmentLabel && (
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{result.assignmentLabel}
</span>
)}
{alreadySelected && (
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
Selected
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
</div>
);
}

View file

@ -160,3 +160,85 @@ export async function notifyAwardGrants(
console.error("[Awards] Failed to send award-grant notifications:", err);
}
}
export interface CleanupResult {
profilesChecked: number;
profilesModified: number;
duplicatesRemoved: number;
details: { profileId: number; removed: number }[];
}
export async function cleanupDuplicateAwards(
payload: PayloadType,
): Promise<CleanupResult> {
const result: CleanupResult = {
profilesChecked: 0,
profilesModified: 0,
duplicatesRemoved: 0,
details: [],
};
let page = 1;
let hasMore = true;
while (hasMore) {
const profiles = await payload.find({
collection: "profiles",
limit: 500,
page,
depth: 0,
overrideAccess: true,
});
result.profilesChecked += profiles.totalDocs;
for (const profile of profiles.docs) {
const awards = (profile as unknown as ProgressionDoc).progression?.awards;
if (!awards || awards.length <= 1) continue;
const seen = new Map<number, { entry: AwardGrantEntry; index: number }>();
const indicesToRemove: number[] = [];
for (let i = 0; i < awards.length; i++) {
const entry = awards[i];
const id = awardIdOf(entry);
if (id === null) continue;
const existing = seen.get(id);
if (existing) {
if (entry.awardedAt < existing.entry.awardedAt) {
indicesToRemove.push(existing.index);
seen.set(id, { entry, index: i });
} else {
indicesToRemove.push(i);
}
} else {
seen.set(id, { entry, index: i });
}
}
if (indicesToRemove.length > 0) {
const cleaned = awards.filter((_, i) => !indicesToRemove.includes(i));
await payload.update({
collection: "profiles",
id: profile.id,
data: {
progression: {
...(profile as unknown as ProgressionDoc).progression,
awards: cleaned,
},
},
overrideAccess: true,
});
result.profilesModified += 1;
result.duplicatesRemoved += indicesToRemove.length;
result.details.push({ profileId: profile.id, removed: indicesToRemove.length });
}
}
hasMore = page < profiles.totalPages;
page += 1;
}
return result;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "awards" ADD COLUMN "background_image_id" integer;
ALTER TABLE "awards" ADD CONSTRAINT "awards_background_image_id_media_id_fk" FOREIGN KEY ("background_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "awards_background_image_idx" ON "awards" USING btree ("background_image_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "awards" DROP CONSTRAINT "awards_background_image_id_media_id_fk";
DROP INDEX "awards_background_image_idx";
ALTER TABLE "awards" DROP COLUMN "background_image_id";`)
}

View file

@ -14,6 +14,7 @@ import * as migration_20260831_180233_assignment_transfers from './20260831_1802
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
export const migrations = [
{
@ -94,6 +95,11 @@ export const migrations = [
{
up: migration_20260901_222200_add_admin_page_permissions.up,
down: migration_20260901_222200_add_admin_page_permissions.down,
name: '20260901_222200_add_admin_page_permissions'
name: '20260901_222200_add_admin_page_permissions',
},
{
up: migration_20260902_195336_add_medal_backgrounds.up,
down: migration_20260902_195336_add_medal_backgrounds.down,
name: '20260902_195336_add_medal_backgrounds'
},
];

View file

@ -1824,6 +1824,10 @@ export interface Award {
| number
| boolean
| null;
/**
* A background image for the award card. Displayed as a faded backdrop behind the card content.
*/
backgroundImage?: (number | null) | Media;
medalDesign?:
| {
[k: string]: unknown;
@ -3805,6 +3809,7 @@ export interface AwardsSelect<T extends boolean = true> {
image?: T;
experiencePoints?: T;
ribbonDesign?: T;
backgroundImage?: T;
medalDesign?: T;
grantConfig?:
| T

View file

@ -0,0 +1,14 @@
import { getPayload } from "payload";
import config from "@payload-config";
(async () => {
const payload = await getPayload({ config });
// payload.logger.log("Retrieving user profiles...");
// await payload.find({
// collection: "profiles",
// overrideAccess: true,
// select
// })
})();