1
0
Fork 0

feat: add XP display component and experience collection, integrate user XP in layout

This commit is contained in:
Jason Fraley 2026-01-09 01:26:40 -05:00
parent 773f6a29c1
commit d04630ca65
11 changed files with 11407 additions and 16 deletions

View file

@ -15,6 +15,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
@ -552,6 +553,8 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "optionalDependencies": { "@types/react": "19.2.7", "@types/react-dom": "19.2.3" }, "peerDependencies": { "react": "19.2.3", "react-dom": "19.2.3" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "19.2.7", "@types/react-dom": "19.2.3" }, "peerDependencies": { "react": "19.2.3", "react-dom": "19.2.3" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "optionalDependencies": { "@types/react": "19.2.7", "@types/react-dom": "19.2.3" }, "peerDependencies": { "react": "19.2.3", "react-dom": "19.2.3" } }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],

View file

@ -34,6 +34,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",

11219
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,26 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
let userXp = 0;
if (user) {
const profileRes = await payload.find({
collection: "profiles",
where: {
user: {
equals: user.id,
},
},
select: {
progression: {
awards: true,
xp: true,
},
},
});
console.log(profileRes);
userXp = profileRes.docs[0]?.progression?.xp ?? 0;
}
return (
<html lang="en" className="bg-black leading-8 antialiased h-full dark">
<body className="h-full font-sans m-0 text-white">
@ -31,6 +51,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
displayName: user?.payloadDisplayName ?? "Guest",
username: user?.username ?? "Not logged in",
}}
xp={userXp}
adminUrl={payloadConfig.routes.admin}
className="border-r"
/>

View file

@ -0,0 +1,37 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Experience: CollectionConfig = {
slug: "experience",
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
},
admin: {
group: "Users",
useAsTitle: "name",
},
labels: {
plural: "XP Levels",
singular: "XP Level",
},
fields: [
{
name: "name",
type: "text",
required: true,
unique: true,
},
{
name: "requiredPoints",
type: "number",
required: true,
},
{
name: "description",
type: "richText",
required: true,
},
],
};

View file

@ -74,12 +74,16 @@ export const Profiles: CollectionConfig = {
async ({ req, siblingData }) => {
let xpAcc = 0;
const awardsList = (siblingData as { awards?: number[] })?.awards || [];
const scoreboard = (siblingData as { scoreboard?: Record<string, number | null> })?.scoreboard || {};
if (awardsList.length > 0) {
await req.payload
.find({
collection: "awards",
where: {
id: {
in: (siblingData as { awards: number[] }).awards,
in: awardsList,
},
},
})
@ -87,11 +91,11 @@ export const Profiles: CollectionConfig = {
const awards: Award[] = res.docs;
xpAcc += awards.reduce((prev, cur) => prev + (cur.experiencePoints ?? 0), 0);
});
}
Object.entries(
(siblingData as { scoreboard: Record<string, number | null> }).scoreboard,
).forEach(([k, v]) => {
Object.entries(scoreboard).forEach(([k, v]) => {
if (!v) return;
switch (k) {
case "vehicleKills":
xpAcc += v * 100;

View file

@ -28,6 +28,7 @@ import {
} from "@/components/ui/sidebar";
import Image from "next/image";
import Link from "next/link";
import XPDisplay from "@/components/frontend/blocks/XPDisplay";
const data = {
navMain: [
@ -115,6 +116,7 @@ const data = {
export function AppSidebar({
user,
xp,
adminUrl,
...props
}: {
@ -122,6 +124,7 @@ export function AppSidebar({
displayName: string;
username: string;
};
xp: number;
adminUrl?: string;
} & React.ComponentProps<typeof Sidebar>) {
return (
@ -170,6 +173,7 @@ export function AppSidebar({
}
className="mt-auto"
/>
<XPDisplay xp={xp} />
</SidebarContent>
<SidebarFooter>
<NavUser

View file

@ -0,0 +1,26 @@
"use client";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Progress } from "@/components/ui/progress";
import { SparkleIcon } from "lucide-react";
const XPDisplay = ({ xp }: { xp: number }) => {
return (
<Item>
<ItemHeader>
<ItemTitle className="flex justify-between items-center w-full">
<SparkleIcon />
<div className="flex flex-col items-end">
<span className="uppercase">Rookie</span>
<span className="text-2xl font-bold uppercase">{xp ?? 0}</span>
</div>
</ItemTitle>
</ItemHeader>
<ItemContent>
<Progress value={33} />
</ItemContent>
</Item>
);
};
export default XPDisplay;

View file

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View file

@ -76,6 +76,7 @@ export interface Config {
awards: Award;
qualifications: Qualification;
assignments: Assignment;
experience: Experience;
missions: Mission;
campaigns: Campaign;
factions: Faction;
@ -111,6 +112,7 @@ export interface Config {
awards: AwardsSelect<false> | AwardsSelect<true>;
qualifications: QualificationsSelect<false> | QualificationsSelect<true>;
assignments: AssignmentsSelect<false> | AssignmentsSelect<true>;
experience: ExperienceSelect<false> | ExperienceSelect<true>;
missions: MissionsSelect<false> | MissionsSelect<true>;
campaigns: CampaignsSelect<false> | CampaignsSelect<true>;
factions: FactionsSelect<false> | FactionsSelect<true>;
@ -1001,6 +1003,32 @@ export interface Assignment {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "experience".
*/
export interface Experience {
id: number;
name: string;
requiredPoints: number;
description: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "missions".
@ -1375,6 +1403,10 @@ export interface PayloadLockedDocument {
relationTo: 'assignments';
value: number | Assignment;
} | null)
| ({
relationTo: 'experience';
value: number | Experience;
} | null)
| ({
relationTo: 'missions';
value: number | Mission;
@ -1623,6 +1655,17 @@ export interface AssignmentsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "experience_select".
*/
export interface ExperienceSelect<T extends boolean = true> {
name?: T;
requiredPoints?: T;
description?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "missions_select".

View file

@ -28,6 +28,7 @@ import { Technologies } from "@/collections/intelligence/Technologies";
import { GameRules } from "@/collections/game/GameRules";
import { GameStructures } from "@/collections/game/GameStructures";
import { GameHardResources } from "@/collections/game/GameHardResources";
import { Experience } from "@/collections/users/Experience";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
@ -73,6 +74,7 @@ export default buildConfig({
Awards,
Qualifications,
Assignments,
Experience,
// Intel
Missions,