1
0
Fork 0

Compare commits

...

3 commits

12 changed files with 326 additions and 52 deletions

View file

@ -2,7 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Assignment, Award, Media, Qualification, Rank, User } from "@/payload-types"; import type { Assignment, Media, Qualification, Rank, User } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { import {
@ -13,6 +13,7 @@ import {
CrosshairIcon, CrosshairIcon,
FileTextIcon, FileTextIcon,
HashIcon, HashIcon,
HourglassIcon,
PlaneIcon, PlaneIcon,
ShieldCheck, ShieldCheck,
ShieldIcon, ShieldIcon,
@ -70,9 +71,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const profile = profileRes.docs[0]; const profile = profileRes.docs[0];
const user = profile.user as User; const user = profile.user as User;
const rank = profile.rank as Rank; const rank = profile.rank as Rank;
const awards = (profile.progression?.awards ?? []).filter( const awards = (profile.progression?.awards ?? []).flatMap((entry) => {
(a): a is Award => typeof a === "object" && a !== null, if (typeof entry !== "object" || entry === null) return [];
);
const award = entry.award;
return typeof award === "object" && award !== null ? [award] : [];
});
const qualifications = (profile.progression?.qualifications ?? []).filter( const qualifications = (profile.progression?.qualifications ?? []).filter(
(q): q is Qualification => typeof q === "object" && q !== null, (q): q is Qualification => typeof q === "object" && q !== null,
); );
@ -252,9 +256,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
<Item variant="outline"> <Item variant="outline">
<ItemHeader> <ItemHeader>
<ItemTitle>Intelligence Record</ItemTitle> <ItemTitle>Intelligence Record</ItemTitle>
{profile.dossier?.intelExcerpt ? (
<div className="inline-flex flex-row items-center gap-1 text-xs text-amber-300"> <div className="inline-flex flex-row items-center gap-1 text-xs text-amber-300">
<span>Verified by the Intelligence Division</span> <ShieldCheck className="w-4" /> <span>Verified by the Intelligence Division</span> <ShieldCheck className="w-4" />
</div> </div>
) : (
<div className="inline-flex flex-row items-center gap-1 text-xs opacity-25">
<span>Undergoing review by the Intelligence Division</span>{" "}
<HourglassIcon className="w-4" />
</div>
)}
</ItemHeader> </ItemHeader>
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic"> <ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
{profile.dossier?.intelExcerpt ? ( {profile.dossier?.intelExcerpt ? (

View file

@ -320,6 +320,62 @@
box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9); box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9);
} }
@property --medal-border-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes border-spin-animate {
from {
--medal-border-angle: 0deg;
}
to {
--medal-border-angle: 360deg;
}
}
.medal-card {
--medal-border-angle: 0deg;
background: rgb(193 161 79 / 0.28);
position: relative;
isolation: isolate;
}
.medal-card::before {
content: '';
position: absolute;
inset: -0.125rem;
z-index: 0;
border-radius: inherit;
border: 2px solid transparent;
background: linear-gradient(var(--background), var(--background)) padding-box, repeating-conic-gradient(from var(--medal-border-angle), transparent 0deg 150deg, peru 159deg, rgb(253 224 71) 168deg, white 178deg, transparent 178deg) border-box;
animation: border-spin-animate 10s linear infinite;
pointer-events: none;
}
@keyframes border-spin-animate {
from {
--medal-border-angle: 0deg;
}
to {
--medal-border-angle: 360deg;
}
}
.medal-card * {
position: relative;
z-index: 1;
}
.medal-card::after {
content: '';
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;
}
/* Scrollbars — styled to match the dark frontend theme */ /* Scrollbars — styled to match the dark frontend theme */
* { * {
scrollbar-width: thin; scrollbar-width: thin;

View file

@ -107,21 +107,29 @@ export const Profiles: CollectionConfig = {
let xpAcc = 0; let xpAcc = 0;
const progressionData = siblingData as { const progressionData = siblingData as {
awards?: number[]; awards?: Array<{
award?: number | Award;
} | number>;
scoreboard?: Record<string, number | null>; scoreboard?: Record<string, number | null>;
minigames?: { flappyBird?: { xpEarned?: number | null } }; minigames?: { flappyBird?: { xpEarned?: number | null } };
}; };
const awardsList = progressionData?.awards || []; const awardsList = progressionData?.awards || [];
const awardIds = awardsList.flatMap((entry) => {
const award = typeof entry === "object" ? entry.award : entry;
if (typeof award === "number") return [award];
if (award && typeof award.id === "number") return [award.id];
return [];
});
const scoreboard = progressionData?.scoreboard || {}; const scoreboard = progressionData?.scoreboard || {};
const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0; const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0;
if (awardsList.length > 0) { if (awardIds.length > 0) {
await req.payload await req.payload
.find({ .find({
collection: "awards", collection: "awards",
where: { where: {
id: { id: {
in: awardsList, in: awardIds,
}, },
}, },
}) })
@ -159,13 +167,46 @@ export const Profiles: CollectionConfig = {
}, },
{ {
name: "awards", name: "awards",
type: "array",
fields: [
{
name: "award",
type: "relationship", type: "relationship",
relationTo: "awards", relationTo: "awards",
hasMany: true, required: true,
admin: { admin: {
appearance: "drawer", appearance: "drawer"
}, },
}, },
{
name: "awardedBy",
type: "relationship",
relationTo: "profiles",
// This is a self-relation. Do not recursively populate the
// issuer's progression/awards when a profile is read.
maxDepth: 0,
admin: {
description: 'Who issued the award. If no one, the award will be attributed as "on behalf of the unit."',
},
},
{
name: "awardedAt",
type: "date",
required: true,
defaultValue: () => new Date().toISOString(),
admin: {
description: "The issuing date of the award.",
},
},
{
name: "awardReason",
type: "textarea",
admin: {
description: "An optional reason for the award.",
},
},
],
},
{ {
name: "qualifications", name: "qualifications",
type: "relationship", type: "relationship",

View file

@ -3,14 +3,17 @@ import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/com
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 { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import { cn } from "@/lib/utils";
export default function AwardCard(props: { export default function AwardCard(props: { awardImage: Media | null; award: Award }) {
awardImage: Media | null;
award: Award;
}) {
return ( return (
<Item variant="outline"> <Item
<ItemHeader className="flex flex-col items-center text-center"> variant="outline"
className={cn("text-center", {
"medal-card": props.award.type === "medal",
})}
>
<ItemHeader className="flex flex-col items-center">
{props.award.ribbonDesign && ( {props.award.ribbonDesign && (
<RibbonViewer <RibbonViewer
ribbonDesign={props.award.ribbonDesign as RibbonDesign} ribbonDesign={props.award.ribbonDesign as RibbonDesign}
@ -26,7 +29,9 @@ export default function AwardCard(props: {
/> />
)} )}
<div className="flex flex-col"> <div className="flex flex-col">
<ItemTitle>{props.award.name}</ItemTitle> <ItemTitle className={cn({ "text-xl": props.award.type === "medal" })}>
{props.award.name}
</ItemTitle>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono"> <span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono">
{props.award.type ?? "commendation"} {props.award.type ?? "commendation"}
</span> </span>

View file

@ -8,13 +8,13 @@ export const RibbonRack = ({ awards }: { awards: Award[] }) => {
return ( return (
<div className="flex flex-row w-52 gap-1"> <div className="flex flex-row w-52 gap-1">
{awards {awards
.filter((award) => award.ribbonDesign !== null) .filter((award) => award.type === "ribbon" && award.ribbonDesign != null)
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0)) .sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
.map((award, key) => { .map((award, key) => {
const ribbon = award.ribbonDesign as RibbonDesign; const ribbon = award.ribbonDesign as RibbonDesign;
return ( return (
<Tooltip key={key}> <Tooltip key={key}>
<TooltipTrigger className="w-full hover:scale-110 transition-transform"> <TooltipTrigger className="w-full hover:scale-110 shadow-black shadow-xs hover:shadow-xl transition-transform">
<RibbonViewer ribbonDesign={ribbon} showRibbonOnly /> <RibbonViewer ribbonDesign={ribbon} showRibbonOnly />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>

View file

@ -115,8 +115,7 @@ export function Wardrobe({
<TooltipContent <TooltipContent
side="right" side="right"
sideOffset={8} sideOffset={8}
className="bg-popover text-popover-foreground border border-border text-sm px-3.5 py-2" className="text-sm px-3.5 py-2"
arrowClassName="bg-popover fill-popover"
> >
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="font-semibold">{layout.label}</span> <span className="font-semibold">{layout.label}</span>

View file

@ -2,9 +2,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { VersionInfo } from "@/lib/versionInfo"; import type { VersionInfo } from "@/lib/versionInfo";
import { getPayload } from "payload";
import config from "@payload-config";
import { User } from "@/payload-types";
const EMPTY: VersionInfo = { const EMPTY: VersionInfo = {
version: null, version: null,

View file

@ -34,24 +34,29 @@ function TooltipContent({
className, className,
sideOffset = 0, sideOffset = 0,
children, children,
arrowClassName,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) { }: React.ComponentProps<typeof TooltipPrimitive.Content> & { arrowClassName?: string }) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-background px-3 py-1.5 text-xs text-balance text-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-popover border border-border px-3 py-1.5 text-xs text-balance text-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-background fill-background" /> <TooltipPrimitive.Arrow
className={
cn("z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-popover fill-popover", arrowClassName)
}
/>
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -0,0 +1,122 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
const awardsTable = "profiles_progression_awards";
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "${sql.raw(awardsTable)}" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"award_id" integer NOT NULL,
"awarded_by_id" integer,
"awarded_at" timestamp(3) with time zone NOT NULL,
"award_reason" varchar
);
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "${sql.raw(awardsTable)}"
ADD CONSTRAINT "profiles_progression_awards_parent_id_fk"
FOREIGN KEY ("_parent_id") REFERENCES "public"."profiles"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "${sql.raw(awardsTable)}"
ADD CONSTRAINT "profiles_progression_awards_award_id_fk"
FOREIGN KEY ("award_id") REFERENCES "public"."awards"("id")
ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "${sql.raw(awardsTable)}"
ADD CONSTRAINT "profiles_progression_awards_awarded_by_id_fk"
FOREIGN KEY ("awarded_by_id") REFERENCES "public"."profiles"("id")
ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "profiles_progression_awards_order_idx"
ON "${sql.raw(awardsTable)}" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "profiles_progression_awards_parent_id_idx"
ON "${sql.raw(awardsTable)}" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "profiles_progression_awards_award_id_idx"
ON "${sql.raw(awardsTable)}" USING btree ("award_id");
CREATE INDEX IF NOT EXISTS "profiles_progression_awards_awarded_by_id_idx"
ON "${sql.raw(awardsTable)}" USING btree ("awarded_by_id");
`);
// The old relationship did not record award metadata. Preserve its award,
// profile, and ordering; use the profile creation date as the best available
// timestamp for these historical entries.
await db.execute(sql`
INSERT INTO "${sql.raw(awardsTable)}"
("_order", "_parent_id", "id", "award_id", "awarded_at")
SELECT
ROW_NUMBER() OVER (
PARTITION BY rels."parent_id"
ORDER BY rels."order" NULLS LAST, rels."id"
) - 1,
rels."parent_id",
md5('profile-award:' || rels."id"),
rels."awards_id",
profiles."created_at"
FROM "profiles_rels" rels
INNER JOIN "profiles" profiles ON profiles."id" = rels."parent_id"
WHERE rels."awards_id" IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM "${sql.raw(awardsTable)}" existing
WHERE existing."id" = md5('profile-award:' || rels."id")
);
`);
await db.execute(sql`
ALTER TABLE "profiles_rels"
DROP CONSTRAINT IF EXISTS "profiles_rels_awards_fk";
DROP INDEX IF EXISTS "profiles_rels_awards_id_idx";
ALTER TABLE "profiles_rels" DROP COLUMN IF EXISTS "awards_id";
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "profiles_rels" ADD COLUMN IF NOT EXISTS "awards_id" integer;
`);
await db.execute(sql`
INSERT INTO "profiles_rels" ("order", "parent_id", "path", "awards_id")
SELECT awards."_order", awards."_parent_id", 'progression.awards', awards."award_id"
FROM "${sql.raw(awardsTable)}" awards
WHERE NOT EXISTS (
SELECT 1
FROM "profiles_rels" rels
WHERE rels."parent_id" = awards."_parent_id"
AND rels."awards_id" = awards."award_id"
AND rels."path" = 'progression.awards'
);
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "profiles_rels"
ADD CONSTRAINT "profiles_rels_awards_fk"
FOREIGN KEY ("awards_id") REFERENCES "public"."awards"("id")
ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
CREATE INDEX IF NOT EXISTS "profiles_rels_awards_id_idx"
ON "profiles_rels" USING btree ("awards_id");
DROP TABLE IF EXISTS "${sql.raw(awardsTable)}";
`);
}

View file

@ -2,6 +2,7 @@ import * as migration_20260818_024510 from './_archived/20260818_024510';
import * as migration_20260820_004157 from './20260820_004157'; import * as migration_20260820_004157 from './20260820_004157';
import * as migration_20260821_043007 from './20260821_043007'; import * as migration_20260821_043007 from './20260821_043007';
import * as migration_20260821_060000 from './20260821_060000'; import * as migration_20260821_060000 from './20260821_060000';
import * as migration_20260821_120000 from './20260821_120000';
export const migrations = [ export const migrations = [
{ {
@ -19,4 +20,9 @@ export const migrations = [
down: migration_20260821_060000.down, down: migration_20260821_060000.down,
name: '20260821_060000' name: '20260821_060000'
}, },
{
up: migration_20260821_120000.up,
down: migration_20260821_120000.down,
name: '20260821_120000'
},
]; ];

View file

@ -1610,7 +1610,24 @@ export interface Profile {
}; };
progression?: { progression?: {
xp?: number | null; xp?: number | null;
awards?: (number | Award)[] | null; awards?:
| {
award: number | Award;
/**
* Who issued the award. If no one, the award will be attributed as "on behalf of the unit."
*/
awardedBy?: (number | null) | Profile;
/**
* The issuing date of the award.
*/
awardedAt: string;
/**
* An optional reason for the award.
*/
awardReason?: string | null;
id?: string | null;
}[]
| null;
qualifications?: (number | Qualification)[] | null; qualifications?: (number | Qualification)[] | null;
scoreboard?: { scoreboard?: {
infantryKills?: number | null; infantryKills?: number | null;
@ -3239,7 +3256,15 @@ export interface ProfilesSelect<T extends boolean = true> {
| T | T
| { | {
xp?: T; xp?: T;
awards?: T; awards?:
| T
| {
award?: T;
awardedBy?: T;
awardedAt?: T;
awardReason?: T;
id?: T;
};
qualifications?: T; qualifications?: T;
scoreboard?: scoreboard?:
| T | T

View file

@ -33,8 +33,15 @@ export async function hasIntelligenceQualification(
const profile = (await payload.find({ const profile = (await payload.find({
collection: "profiles", collection: "profiles",
where: { user: { equals: user.id as number } }, where: { user: { equals: user.id as number } },
depth: 4, // Only qualifications are needed here. A deeper read recursively
// hydrates Profiles through progression.awards.awardedBy.
depth: 1,
limit: 1, limit: 1,
select: {
progression: {
qualifications: true,
},
},
})) as unknown as { docs: Array<any> }; })) as unknown as { docs: Array<any> };
const userProfile = profile.docs[0]; const userProfile = profile.docs[0];