1
0
Fork 0

Compare commits

...

2 commits

11 changed files with 19509 additions and 11 deletions

View file

@ -184,8 +184,9 @@ EXPOSE 3000
# Liveness probe hooked into our `/api/health` route (no DB dependency,
# so a transient DB outage does NOT cycle the container).
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT:-3000}/api/health" >/dev/null 2>&1 || exit 1
#HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
# CMD wget -qO- "http://127.0.0.1:${PORT:-3000}/api/health" >/dev/null 2>&1 || exit 1
# NOTE: HEALTHCHECK IS DISABLED HERE - COOLIFY HEALTHCHECK IS EASIER TO MANAGE
COPY --chmod=755 docker-entrypoint.sh /docker-entrypoint.sh

View file

@ -595,8 +595,15 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</div>
{awards.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{awards
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
{[...awards]
.sort((a, b) => {
const typeOrder = { medal: 0, ribbon: 1, commendation: 2 } as const;
const categoryOrder =
(typeOrder[a.type as keyof typeof typeOrder] ?? typeOrder.commendation) -
(typeOrder[b.type as keyof typeof typeOrder] ?? typeOrder.commendation);
return categoryOrder || (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0);
})
.map((award) => {
const awardImage =
award.image && typeof award.image === "object" ? (award.image as Media) : null;

View file

@ -376,6 +376,28 @@
animation: pulse 5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.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;
}
@keyframes award-xp-shimmer {
to {
background-position: 200% center;
}
}
@media (prefers-reduced-motion: reduce) {
.award-xp-high {
animation: none;
}
}
/* Scrollbars — styled to match the dark frontend theme */
* {
scrollbar-width: thin;

View file

@ -4,6 +4,7 @@ import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8c
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalEditorFeatureClient as LexicalEditorFeatureClient_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { default as default_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField'
import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner'
import { default as default_2f2033acccb3be245f1a85f7b4271dac } from '@/components/admin/awards/MedalDesigner'
import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
@ -28,6 +29,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@ai-stack/payloadcms/client#LexicalEditorFeatureClient": LexicalEditorFeatureClient_4490b89d4413c1ffaecdacfe72efaf73,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@/components/admin/awards/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382,
"@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d,
"@/components/admin/awards/MedalDesigner#default": default_2f2033acccb3be245f1a85f7b4271dac,
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,

View file

@ -43,6 +43,17 @@ export const Awards: CollectionConfig = {
],
defaultValue: "commendation",
},
{
name: "commendationColor",
type: "text",
defaultValue: "#92400e",
admin: {
components: {
Field: "@/components/admin/awards/CommendationColorField",
},
condition: (data) => data?.type === "commendation",
},
},
{
name: "image",
type: "upload",

View file

@ -0,0 +1,40 @@
"use client";
import { useField } from "@payloadcms/ui";
const DEFAULT_COLOR = "#92400e";
export default function CommendationColorField() {
const { value, setValue } = useField<string>();
const color = typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value : DEFAULT_COLOR;
return (
<div className="field-type text">
<label className="field-label" htmlFor="commendation-color">
Commendation color
</label>
<div className="flex items-center gap-3">
<input
id="commendation-color"
type="color"
value={color}
onChange={(event) => setValue(event.target.value)}
aria-label="Choose commendation color"
className="h-10 w-14 cursor-pointer rounded border border-border bg-transparent p-1"
/>
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(event) => setValue(event.target.value)}
placeholder={DEFAULT_COLOR}
pattern="^#[0-9a-fA-F]{6}$"
aria-describedby="commendation-color-description"
className="field-type__input"
/>
</div>
<div id="commendation-color-description" className="field-description">
Used for the border and background of this commendation on service records.
</div>
</div>
);
}

View file

@ -5,13 +5,39 @@ import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import { cn } from "@/lib/utils";
const getExperiencePointsClass = (experiencePoints: number) => {
if (experiencePoints >= 5000) return "award-xp-high";
if (experiencePoints >= 1000) return "text-emerald-400";
if (experiencePoints >= 501) return "text-lime-300";
if (experiencePoints >= 200) return "text-sky-300";
return "text-muted-foreground";
};
export default function AwardCard(props: { awardImage: Media | null; award: Award }) {
const isCommendation = props.award.type === "commendation" || !props.award.type;
const commendationColor =
isCommendation &&
typeof props.award.commendationColor === "string" &&
/^#[0-9a-f]{6}$/i.test(props.award.commendationColor)
? props.award.commendationColor
: null;
return (
<Item
variant="outline"
size={isCommendation ? "sm" : "default"}
className={cn("text-center", {
"medal-card": props.award.type === "medal",
"commendation-card": isCommendation,
})}
style={
commendationColor
? {
borderColor: commendationColor,
backgroundColor: `${commendationColor}33`,
}
: undefined
}
>
<ItemHeader className="flex flex-col items-center">
{props.award.ribbonDesign && (
@ -25,27 +51,40 @@ export default function AwardCard(props: { awardImage: Media | null; award: Awar
<img
src={props.awardImage.url}
alt={props.award.name}
className="size-8 object-contain"
className={cn("object-contain", isCommendation ? "size-6" : "size-8")}
/>
)}
<div className="flex flex-col">
<ItemTitle className={cn({ "text-xl": props.award.type === "medal" })}>
<ItemTitle
className={cn({
"text-xl text-shadow-lg text-shadow-white/15": props.award.type === "medal",
"text-xs": isCommendation,
})}
>
{props.award.name}
</ItemTitle>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-mono">
{props.award.type ?? "commendation"}
</span>
</div>
</div>
</ItemHeader>
<ItemContent>
<ItemDescription className="line-clamp-3 text-center">
<ItemDescription
className={cn("text-center", isCommendation ? "line-clamp-none text-xs" : "line-clamp-3")}
>
{props.award.description
? convertLexicalToPlaintext({ data: props.award.description }).slice(0, 120)
? convertLexicalToPlaintext({ data: props.award.description })
: "No description."}
</ItemDescription>
{props.award.experiencePoints != null && (
<span className="text-xs font-mono text-muted-foreground mt-1">
<span
className={cn(
"mt-1 font-mono",
getExperiencePointsClass(props.award.experiencePoints),
isCommendation ? "text-[10px]" : "text-xs",
)}
>
+{props.award.experiencePoints} XP
</span>
)}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
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 "commendation_color" varchar DEFAULT '#92400e';`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "awards" DROP COLUMN "commendation_color";`)
}

View file

@ -7,6 +7,7 @@ import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './202
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
import * as migration_20260828_044506 from './20260828_044506';
export const migrations = [
{
@ -52,6 +53,11 @@ export const migrations = [
{
up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down,
name: '20260828_041615_add_campaign_mode'
name: '20260828_041615_add_campaign_mode',
},
{
up: migration_20260828_044506.up,
down: migration_20260828_044506.down,
name: '20260828_044506'
},
];

View file

@ -1731,6 +1731,7 @@ export interface Award {
[k: string]: unknown;
};
type?: ('commendation' | 'ribbon' | 'medal') | null;
commendationColor?: string | null;
image?: (number | null) | Media;
experiencePoints?: number | null;
ribbonDesign?:
@ -3539,6 +3540,7 @@ export interface AwardsSelect<T extends boolean = true> {
name?: T;
description?: T;
type?: T;
commendationColor?: T;
image?: T;
experiencePoints?: T;
ribbonDesign?: T;