Compare commits
2 commits
53d053b3b2
...
3e99a7f47d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e99a7f47d | |||
| ab86be05ae |
11 changed files with 19509 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
40
src/components/admin/awards/CommendationColorField.tsx
Normal file
40
src/components/admin/awards/CommendationColorField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
19357
src/migrations/20260828_044506.json
Normal file
19357
src/migrations/20260828_044506.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260828_044506.ts
Normal file
11
src/migrations/20260828_044506.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue