1
0
Fork 0

feat: application staging deployment (ensure better commits after this)

This commit is contained in:
Z8MB1E 2025-12-15 01:14:30 -05:00
parent 4d639387f4
commit 2e695bd43f
68 changed files with 26941 additions and 361 deletions

3
.env.test Normal file
View file

@ -0,0 +1,3 @@
DATABASE_URI=postgres://ptfapp:polaristaskforcedb@127.0.0.1:5432/ptf-app-stg
PAYLOAD_SECRET=b5ac11acc77ea8427d10fcd0
# Added by Payload

6
Writerside/c.list Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE categories
SYSTEM "https://resources.jetbrains.com/writerside/1.0/categories.dtd">
<categories>
<category id="wrs" name="Writerside documentation" order="1"/>
</categories>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE buildprofiles SYSTEM "https://resources.jetbrains.com/writerside/1.0/build-profiles.dtd">
<buildprofiles xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/build-profiles.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<variables></variables>
<build-profile instance="ptf-dg">
<variables>
<noindex-content>true</noindex-content>
</variables>
</build-profile>
</buildprofiles>

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

10
Writerside/ptf-ug.tree Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE instance-profile
SYSTEM "https://resources.jetbrains.com/writerside/1.0/product-profile.dtd">
<instance-profile id="ptf-ug"
name="Polaris Task Force - User Guide"
start-page="Overview.md">
<toc-element topic="Overview.md"/>
</instance-profile>

View file

@ -0,0 +1,66 @@
# Overview
The Polaris Task Force website (also known as the "web app") is intended to provide a meta-gaming interface for the
Polaris Task Force Arma 3 unit's various user-created missions and campaigns.
## What is the PTF web app?
Developed with NextJS and powered by the headless CMS PayloadCMS, the PTF web app acts as the main hub for all Polaris
Task Force Arma 3 content. It offers a novel, interactive experience inspired by 4X strategy games, integrating similar
features into the unit's Arma 3 activities.
The web app provides two primary interfaces to users.
1. **Frontend** – This is the main interface for users to interact with the web app. Here, you can manage the unit
budget, purchase assets, construct buildings, read mission briefings, view profiles, and more. The frontend is
essentially the _game_ part of the web app.
2. **Backend** – This is the developer portal and dashboard, where all game definitions, rules, and data are created and
managed. The backend acts as a _game engine_, controlling how the game works behind the scenes.
### The Job System
Beyond the two user interfaces, the web app also manages and processes the "game state" that is created via automated
rules and user interactions within the app. This aspect of the web app is referred to as the **job system**. The job
system is an essential part of the app that runs on a specific time interval called a **"game tick"**, which occurs
every ten minutes.
The job system will process pending user actions as well as recalculate various parts of the game state. For example,
the job system is responsible for finalizing currency transactions, producing new materials from factories, increasing
research progress, and much more.
### Permissions & Roles
Different members of the Polaris Task Force community will have different permissions and controls available to them
through the web app. For example, the unit's logistics team will have administrative access to the definitions of
assets, structures, and any other logistics-related items. The intelligence team will have administrative access to user
profiles, missions, and the unit wiki. Regular users can expect standard (mostly read-only) access to the application,
with exceptions for specific cases.
During the application's development, a **quality-assurance team** will be granted broad
administrative rights, excluding only highly-sensitive, developer-specific areas within the
application. The QA team is responsible for ensuring the stability and functionality of the application throughout its
various stages of development. At the time of writing, the QA team consists of the following unit members:
- **L. Vargas** - Unit Supply Officer, Logistics
- **A. Aries** - Logistics
- **I. Kronus** - Head Intelligence Director, Intelligence
## Glossary
The following terms are used regularly throughout the web app documentation and are compiled here for your convenience.
Frontend
: This is the main interface for users to interact with the web app. Here, you can manage the unit
budget, purchase assets, construct buildings, read mission briefings, view profiles, and more. The frontend is
essentially the _game_ part of the web app.
Backend
: This is the developer portal and dashboard, where all game definitions, rules, and data are created and
managed. The backend acts as a _game engine_, controlling how the game works behind the scenes.
Job system
: The job system is an essential part of the app that automatically runs various game processes over time.
Game tick
: The game tick is a specific time interval that occurs every ten minutes. The server automatically performs various
operations on each game tick.

5
Writerside/v.list Normal file
View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE vars SYSTEM "https://resources.jetbrains.com/writerside/1.0/vars.dtd">
<vars>
<var name="product" value="Writerside"/>
</vars>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ihp SYSTEM "https://resources.jetbrains.com/writerside/1.0/ihp.dtd">
<ihp version="2.0">
<topics dir="topics" web-path="topics"/>
<images dir="images" web-path="images"/>
<instance src="ptf-ug.tree"/>
</ihp>

22
components.json Normal file
View file

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/(frontend)/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

9
drizzle.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/payload-generated-schema.ts",
dbCredentials: {
url: process.env.DATABASE_URI || "",
},
});

View file

@ -1,7 +1,7 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "1.0.0", "version": "0.1.0",
"description": "A blank template to get started with Payload 3.0", "description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -12,25 +12,34 @@
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"db": "cross-env NODE_OPTIONS=--no-deprecation pnpm drizzle-kit",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start", "start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"staging": "cross-env NODE_ENV=test NODE_OPTIONS=--no-deprecation next start",
"test": "pnpm run test:int && pnpm run test:e2e", "test": "pnpm run test:int && pnpm run test:e2e",
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" pnpm exec playwright test", "test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" pnpm exec playwright test",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts" "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
}, },
"dependencies": { "dependencies": {
"@payloadcms/db-postgres": "3.59.1",
"@payloadcms/next": "3.59.1", "@payloadcms/next": "3.59.1",
"@payloadcms/payload-cloud": "3.59.1", "@payloadcms/payload-cloud": "3.59.1",
"@payloadcms/richtext-lexical": "3.59.1", "@payloadcms/richtext-lexical": "3.59.1",
"@payloadcms/ui": "3.59.1", "@payloadcms/ui": "3.59.1",
"@tailwindcss/postcss": "^4.1.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "16.4.7", "dotenv": "16.4.7",
"graphql": "^16.8.1", "graphql": "^16.8.1",
"next": "15.4.4", "lucide-react": "^0.548.0",
"next": "15.4.7",
"payload": "3.59.1", "payload": "3.59.1",
"postcss": "^8.5.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"sharp": "0.34.2", "sharp": "0.34.2",
"@payloadcms/db-postgres": "3.59.1" "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.16"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.54.1", "@playwright/test": "1.54.1",
@ -39,12 +48,14 @@
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@vitejs/plugin-react": "4.5.2", "@vitejs/plugin-react": "4.5.2",
"drizzle-kit": "^0.31.6",
"eslint": "^9.16.0", "eslint": "^9.16.0",
"eslint-config-next": "15.4.4", "eslint-config-next": "15.4.4",
"jsdom": "26.1.0", "jsdom": "26.1.0",
"playwright": "1.54.1", "playwright": "1.54.1",
"playwright-core": "1.54.1", "playwright-core": "1.54.1",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"tw-animate-css": "^1.4.0",
"typescript": "5.7.3", "typescript": "5.7.3",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.3" "vitest": "3.2.3"

File diff suppressed because it is too large Load diff

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

20
public/images/icon.svg Normal file
View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(0.900936,0,0,0.900936,0.990635,0.990635)">
<path d="M10,0.011L11.227,7.842C11.264,8.076 11.374,8.292 11.541,8.459C11.708,8.626 11.924,8.736 12.158,8.773L19.989,10L12.158,11.227C11.924,11.264 11.708,11.374 11.541,11.541C11.374,11.708 11.264,11.924 11.227,12.158L10,19.989L8.773,12.158C8.736,11.924 8.626,11.708 8.459,11.541C8.292,11.374 8.076,11.264 7.842,11.227L0.011,10L7.842,8.773C8.076,8.736 8.292,8.626 8.459,8.459C8.626,8.292 8.736,8.076 8.773,7.842L10,0.011Z"
style="stroke:white;stroke-width:1.39px;fill:white;"/>
</g>
<path d="M12,2C15.122,2.474 17.191,4.35 17.995,8L13,7L12,2Z" style="stroke:white;stroke-width:1.25px;"/>
<g transform="matrix(-1,0,0,1,20,0)">
<path d="M12,2C15.122,2.474 17.191,4.35 17.995,8L13,7L12,2Z" style="stroke:white;stroke-width:1.25px;"/>
</g>
<g transform="matrix(1,0,0,-1,-0.005208,19.994792)">
<path d="M12,2C15.122,2.474 17.191,4.35 17.995,8L13,7L12,2Z" style="stroke:white;stroke-width:1.25px;"/>
</g>
<g transform="matrix(-1,0,0,-1,19.994792,19.994792)">
<path d="M12,2C15.122,2.474 17.191,4.35 17.995,8L13,7L12,2Z" style="stroke:white;stroke-width:1.25px;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/images/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -10,9 +10,9 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props; const { children } = props;
return ( return (
<html lang="en"> <html lang="en" className="bg-black leading-8 antialiased h-full dark">
<body> <body className="h-full font-sans m-0 text-white">
<main>{children}</main> <main className="h-full">{children}</main>
</body> </body>
</html> </html>
); );

View file

@ -1,39 +1,37 @@
import { headers as getHeaders } from "next/headers.js";
import Image from "next/image"; import Image from "next/image";
import { getPayload } from "payload";
import React from "react"; import React from "react";
import { fileURLToPath } from "url";
import config from "@/payload.config"; import config from "@/payload.config";
import "./styles.css"; import "./styles.css";
export default async function HomePage() { export default async function HomePage() {
const headers = await getHeaders(); // const headers = await getHeaders();
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig }); // const payload = await getPayload({ config: payloadConfig });
const { user } = await payload.auth({ headers }); // const { user } = await payload.auth({ headers });
const fileURL = `webstorm://file/${fileURLToPath(import.meta.url)}`; // const fileURL = `webstorm://file/${fileURLToPath(import.meta.url)}`;
return ( return (
<div className="home"> <div className="flex flex-col justify-between items-center h-full p-11.5 max-w-5xl m-auto overflow-hidden">
<div className="content"> <div className="flex flex-col items-center justify-center flex-grow">
<picture> <picture>
<source srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg" /> <source srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg" />
<Image <Image
alt="Payload Logo" alt="Payload AppLogo"
height={65} height={65}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg" src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg"
width={65} width={65}
className="max-w-full h-auto block"
/> />
</picture> </picture>
<h1>Polaris Task Force</h1> <h1 className="my-10 text-6xl leading-20 font-bold text-center">Polaris Task Force</h1>
{/*{!user && <h1>Welcome to your new project.</h1>} {/*{!user && <h1>Welcome to your new project.</h1>}
{user && <h1>Welcome back, {user.email}</h1>}*/} {user && <h1>Welcome back, {user.email}</h1>}*/}
<p>This site is under construction.</p> <p className="my-6">This site is under construction.</p>
<div className="links"> <div className="flex flex-row items-center gap-3">
<a <a
className="admin" className="bg-white text-black border py-1 px-2 rounded"
href={payloadConfig.routes.admin} href={payloadConfig.routes.admin}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
@ -41,7 +39,7 @@ export default async function HomePage() {
Go to admin panel Go to admin panel
</a> </a>
<a <a
className="docs" className="bg-black text-white border border-white py-1 px-2 rounded"
href="https://payloadcms.com/docs" href="https://payloadcms.com/docs"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"

View file

@ -1,164 +1,236 @@
@import "tailwindcss";
@import "tw-animate-css";
/*
---break---
*/
@custom-variant dark (&:is(.dark *));
/*
---break---
*/
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/*
---break---
*/
:root { :root {
--font-mono: 'Roboto Mono', monospace;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
} }
/*
---break---
*/
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
/*
---break---
*/
@layer base {
* { * {
box-sizing: border-box; @apply border-border outline-ring/50;
} }
html {
font-size: 18px;
line-height: 32px;
background: rgb(0, 0, 0);
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body { body {
font-family: system-ui; @apply bg-background text-foreground;
font-size: 18px;
line-height: 32px;
margin: 0;
color: rgb(1000, 1000, 1000);
@media (max-width: 1024px) {
font-size: 15px;
line-height: 24px;
}
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1 {
margin: 40px 0;
font-size: 64px;
line-height: 70px;
font-weight: bold;
@media (max-width: 1024px) {
margin: 24px 0;
font-size: 42px;
line-height: 42px;
}
@media (max-width: 768px) {
font-size: 38px;
line-height: 38px;
}
@media (max-width: 400px) {
font-size: 32px;
line-height: 32px;
}
}
p {
margin: 24px 0;
@media (max-width: 1024px) {
margin: calc(var(--base) * 0.75) 0;
}
}
a {
color: currentColor;
&:focus {
opacity: 0.8;
outline: none;
}
&:active {
opacity: 0.7;
outline: none;
}
}
svg {
vertical-align: middle;
}
.home {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
height: 100vh;
padding: 45px;
max-width: 1024px;
margin: 0 auto;
overflow: hidden;
@media (max-width: 400px) {
padding: 24px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
h1 {
text-align: center;
}
}
.links {
display: flex;
align-items: center;
gap: 12px;
a {
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.admin {
color: rgb(0, 0, 0);
background: rgb(1000, 1000, 1000);
border: 1px solid rgb(0, 0, 0);
}
.docs {
color: rgb(1000, 1000, 1000);
background: rgb(0, 0, 0);
border: 1px solid rgb(1000, 1000, 1000);
}
}
.footer {
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 1024px) {
flex-direction: column;
gap: 6px;
}
p {
margin: 0;
}
.codeLink {
text-decoration: none;
padding: 0 0.5rem;
background: rgb(60, 60, 60);
border-radius: 4px;
}
} }
} }

View file

@ -21,6 +21,10 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon'
import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo'
import { default as default_05548cb43495e62d80b80ec4d9f0f7e9 } from '@/components/static/BeforeDashboardText'
export const importMap = { export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
@ -45,5 +49,9 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
"@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb,
"@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04,
"@/components/static/BeforeDashboardText#default": default_05548cb43495e62d80b80ec4d9f0f7e9
} }

View file

@ -0,0 +1,25 @@
@layer theme {
@import 'tailwindcss/theme.css';
}
@layer base {
.twp {
@import 'tailwindcss/preflight.css';
}
.twp.no-twp {
*,
::after,
::before,
::backdrop,
::file-selector-button {
all: revert-layer;
}
}
}
@layer components;
@layer utilities {
@import 'tailwindcss/utilities.css';
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(0.900936,0,0,0.900936,0.990635,0.990635)">
<path d="M10,0.011L11.227,7.842C11.264,8.076 11.374,8.292 11.541,8.459C11.708,8.626 11.924,8.736 12.158,8.773L19.989,10L12.158,11.227C11.924,11.264 11.708,11.374 11.541,11.541C11.374,11.708 11.264,11.924 11.227,12.158L10,19.989L8.773,12.158C8.736,11.924 8.626,11.708 8.459,11.541C8.292,11.374 8.076,11.264 7.842,11.227L0.011,10L7.842,8.773C8.076,8.736 8.292,8.626 8.459,8.459C8.626,8.292 8.736,8.076 8.773,7.842L10,0.011Z"
style="stroke:white;stroke-width:1.39px;fill:white;"/>
</g>
<path d="M12,2C15.122,2.474 17.191,4.35 17.995,8L13,7L12,2Z" style="stroke:white;stroke-width:1.25px;"/>
<g transform="matrix(-1,0,0,1,20,0)">
<path d="M12,2C15.122,2.474 17.191,4.35 17.995,8L13,7L12,2Z" style="stroke:white;stroke-width:1.25px;"/>
</g>
<g transform="matrix(1,0,0,-1,-0.005208,19.994792)">
<path d="M12,2C15.122,2.474 17.191,4.35 17.995,8L13,7L12,2Z" style="stroke:white;stroke-width:1.25px;"/>
</g>
<g transform="matrix(-1,0,0,-1,19.994792,19.994792)">
<path d="M12,2C15.122,2.474 17.191,4.35 17.995,8L13,7L12,2Z" style="stroke:white;stroke-width:1.25px;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
src/assets/images/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -9,7 +9,6 @@ export const Media: CollectionConfig = {
{ {
name: "alt", name: "alt",
type: "text", type: "text",
required: true,
}, },
], ],
upload: true, upload: true,

View file

@ -0,0 +1,22 @@
import { GlobalConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const GameRules: GlobalConfig = {
slug: "game-rules",
access: {
read: isDeveloper,
update: isDeveloper,
},
fields: [
{
name: "mainCurrency",
type: "relationship",
relationTo: "resources",
required: true,
admin: {
description:
'The base currency ("gold standard") used by all factions to pay for goods and services.',
},
},
],
};

View file

@ -0,0 +1,61 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Campaigns: CollectionConfig = {
slug: "campaigns",
admin: {
group: "Intelligence",
useAsTitle: "name",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
},
fields: [
{
name: "name",
type: "text",
required: true,
unique: true,
},
{
name: "summary",
type: "textarea",
required: true,
defaultValue: "An Arma 3 campaign.",
},
{
name: "status",
type: "select",
options: [
{
label: "Concept",
value: "concept",
},
{
label: "In Progress",
value: "inProgress",
},
{
label: "Completed",
value: "completed",
},
],
required: true,
defaultValue: "concept",
},
{
name: "description",
type: "richText",
},
{
name: "startDate",
type: "date",
},
{
name: "endDate",
type: "date",
},
],
};

View file

@ -0,0 +1,74 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Factions: CollectionConfig = {
slug: "factions",
admin: {
group: "Intelligence",
useAsTitle: "name",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
},
fields: [
{
name: "name",
type: "text",
required: true,
unique: true,
},
{
name: "type",
type: "select",
options: [
{
label: "Major Faction",
value: "majorFaction",
},
{
label: "Minor Faction",
value: "minorFaction",
},
{
label: "Corporation",
value: "corporation",
},
{
label: "Special",
value: "special",
},
],
required: true,
defaultValue: "minorFaction",
},
{
name: "description",
type: "richText",
},
{
name: "playableFaction",
type: "checkbox",
admin: {
description:
"Whether or not this faction is playable. Some factions may be specifically intended to be unplayable based on narrative requirements.",
},
},
{
name: "startingFaction",
type: "checkbox",
admin: {
description:
"Whether or not this faction is/was available as a starting faction at the beginning of the campaign.",
},
},
{
name: "associatedCampaigns",
type: "relationship",
relationTo: "campaigns",
hasMany: true,
},
],
};

View file

@ -0,0 +1,637 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Missions: CollectionConfig = {
slug: "missions",
admin: {
group: "Intelligence",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "codeName",
type: "text",
admin: {
description:
'A "code name" provides a unique way to reference this mission beyond just the mission\'s name. For example, a mission called "Operation Dragon Break" could be codenamed "OP:DRGBRK".',
},
required: true,
unique: true,
},
{
name: "summary",
type: "textarea",
admin: {
description:
"A summary of the mission. This is not the same as a briefing; instead, it describes the mission's theme and concept to the unit as a whole.",
},
required: true,
},
{
name: "operationType",
type: "select",
options: [
{
label: "Main Operation",
value: "main",
},
{
label: "Side Operation",
value: "side",
},
{
label: "External Operation",
value: "external",
},
],
filterOptions: ({ req }) => {
return [
{
label: "Side Operation",
value: "side",
},
{
label: "External Operation",
value: "external",
},
...(isDeveloper({ req: req })
? [
{
label: "Main Operation",
value: "main",
},
]
: []),
];
},
defaultValue: ({ req }) => (isDeveloper({ req: req }) ? "main" : "side"),
required: true,
},
{
type: "tabs",
tabs: [
{
label: "Mission Configuration",
description:
"Essential mission configuration, like the type of mission, what map it is played on, who authored it, etc.",
fields: [
{
name: "classification",
type: "group",
fields: [
{
name: "map",
type: "relationship",
relationTo: "maps",
required: true,
},
{
name: "missionType",
type: "select",
options: ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"],
defaultValue: "Zeus",
required: true,
},
{
name: "campaign",
type: "relationship",
relationTo: "campaigns",
required: true,
},
{
name: "customScripts",
type: "checkbox",
label: "Contains Custom Scripts",
admin: {
description: "This mission contains custom SQF scripts.",
},
},
{
name: "startDateTime",
type: "date",
admin: {
date: {
pickerAppearance: "dayAndTime",
},
},
defaultValue: () => {
const today = new Date();
const day = today.getDay();
const diff = (6 - day) % 7;
const nextSaturday = new Date(today);
nextSaturday.setDate(today.getDate() + diff + 7);
nextSaturday.setHours(20, 0, 0, 0);
return nextSaturday;
},
required: true,
},
{
name: "estimatedDuration",
type: "number",
label: "Estimated Duration (in minutes)",
defaultValue: 60 * 4, // Usually about 4 hours
required: true,
},
],
},
{
name: "ownershipAndStatus",
type: "group",
label: "Ownership & Status",
fields: [
{
name: "authors",
type: "relationship",
relationTo: "users",
hasMany: true,
required: true,
},
{
name: "zeus",
type: "relationship",
relationTo: "users",
hasMany: true,
required: false,
},
{
name: "status",
type: "select",
options: [
"Concept",
"Planning",
"Ready",
"Scheduled",
"Active",
"Completed",
"Cancelled",
],
defaultValue: "Concept",
required: true,
},
{
name: "visibility",
type: "select",
options: [
{
label: "Entire Unit",
value: "unit",
},
{
label: "Leadership & Intel Only",
value: "leadership",
},
{
label: "Intel Only",
value: "intel",
},
{
label: "Private",
value: "private",
},
],
defaultValue: "leadership",
required: true,
},
],
},
{
name: "missionRoles",
type: "group",
fields: [
{
name: "maxPlayers",
type: "number",
defaultValue: 32,
max: 32,
required: true,
},
{
name: "providedRoles",
type: "array",
label: "Roles Provided",
fields: [
{
name: "roleName",
type: "text",
required: true,
},
{
name: "missionCritical",
type: "checkbox",
admin: {
description:
"If true, this mission cannot be executed without someone fulfilling this role.",
},
defaultValue: true,
},
{
name: "requiredMos",
type: "relationship",
hasMany: true,
relationTo: "qualifications",
label: "Required MOS/Qualifications",
admin: {
description:
"This role can only be fulfilled by someone possessing these qualifications.",
},
},
{
name: "minimumRank",
type: "relationship",
relationTo: "ranks",
admin: {
description:
"This role can only be fulfilled by someone of this rank or higher.",
},
},
],
},
{
name: "notes",
type: "textarea",
},
],
},
],
},
{
label: "Narrative",
description:
"Narrative details, like the mission briefing, objectives, civilian presence, RoEs, etc.",
fields: [
{
name: "briefing",
type: "array",
maxRows: 8,
labels: {
plural: "Briefing Entries",
singular: "Briefing Entry",
},
fields: [
{
name: "sectionName",
type: "text",
required: true,
},
{
name: "details",
type: "richText",
required: true,
},
],
defaultValue: [
{
sectionName: "Situation",
},
{
sectionName: "Mission",
},
{
sectionName: "Execution",
},
{
sectionName: "Signals",
},
],
},
{
name: "missionObjectives",
type: "array",
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "description",
type: "textarea",
},
{
name: "missionCritical",
admin: {
description:
"If true, this mission will be considered a failure if this objective is failed.",
},
type: "checkbox",
defaultValue: true,
},
{
name: "successOrFailConditions",
type: "textarea",
label: "Success / Fail Conditions",
},
],
},
{
name: "civilianPresence",
type: "select",
options: ["None", "Low", "Medium", "High"],
defaultValue: "None",
},
{
name: "factionsInvolved",
type: "array",
fields: [
{
name: "factionName",
type: "text",
required: true,
},
{
name: "threatLevel",
type: "select",
options: [
{
label: "Allied",
value: "ally",
},
{
label: "Friendly",
value: "friendly",
},
{
label: "Neutral",
value: "neutral",
},
{
label: "Hostile",
value: "hostile",
},
],
defaultValue: "hostile",
required: true,
},
],
},
],
},
{
label: "Dedicated Server",
description: "Server-specific details, like the IP, port, mission file, mod list, etc.",
fields: [
{
name: "gameDetails",
type: "group",
fields: [
{
name: "serverDetails",
type: "group",
fields: [
{
name: "serverIp",
type: "text",
label: "Server IP or Domain Name",
defaultValue: "arma.onyxsimple.com",
required: true,
},
{
name: "serverPort",
type: "number",
defaultValue: 2302,
required: true,
},
{
name: "serverPassword",
type: "text",
hidden: true,
defaultValue: "ptf313",
},
],
},
{
name: "gameSetup",
type: "group",
fields: [
{
name: "missionFile",
type: "upload",
relationTo: "mission-files",
required: true,
},
{
name: "requiredMods",
type: "upload",
admin: {
description:
"This field is not required only because you may be running a vanilla gamemode. If you have any mods at all in your mission, you MUST include a mod list.",
},
relationTo: "mod-lists",
},
],
},
],
},
],
},
{
label: "Advanced Details",
description:
"Advanced mission details and configuration. Most of these fields are optional, but for those who want extra-detailed missions, this section is for you.",
fields: [
{
name: "logisticsAndSupport",
type: "group",
label: "Logistics & Support",
admin: {
description:
"Mission requirements and conditions surrounding the deployment and use of logistics and support assets during the mission's runtime.",
},
fields: [
{
type: "row",
fields: [
{
name: "insertion",
label: "Allowed/Provided Insertion Method(s)",
type: "select",
options: [
{
label: "Helicopter",
value: "helicopter",
},
{
label: "Aircraft",
value: "aircraft",
},
{
label: "HALO / Paradrop",
value: "paradrop",
},
{
label: "Boat",
value: "boat",
},
{
label: "Car",
value: "car",
},
{
label: "On Foot",
value: "onFoot",
},
],
hasMany: true,
},
{
name: "extraction",
label: "Allowed/Provided Extraction Method(s)",
type: "select",
options: [
{
label: "Helicopter",
value: "helicopter",
},
{
label: "Aircraft",
value: "aircraft",
},
{
label: "HALO / Paradrop",
value: "paradrop",
},
{
label: "Boat",
value: "boat",
},
{
label: "Car",
value: "car",
},
{
label: "On Foot",
value: "onFoot",
},
],
hasMany: true,
},
],
},
{
name: "assetsAvailable",
type: "array",
labels: {
plural: "Assets",
singular: "Asset",
},
fields: [
{
name: "assetType",
type: "text",
admin: {
description:
'The name or type of the asset provided (e.g. "Artillery", "M1A1 Tank", "Fuel Truck", "Close Air Support", etc.)',
},
required: true,
},
{
name: "assetCount",
type: "text",
admin: {
description:
"If applicable, the number of this asset that will be available at the start of the mission.",
},
},
{
name: "doesRespawn",
type: "checkbox",
label: "Is Respawnable?",
admin: {
description:
"Whether or not this asset respawns during the mission, either automatically or manually via Zeus.",
},
},
{
name: "assetCallsign",
type: "text",
admin: {
description:
"If applicable, the callsign of this asset (such as an aicraft providing close air support.)",
},
},
],
},
],
},
{
name: "commsAndDocs",
type: "group",
label: "Comms & SOPs",
admin: {
description:
"Communications and Standard Operating Procedures specific to this mission.",
},
fields: [
{
name: "radioPlan",
type: "richText",
admin: {
description:
"A detailed description of how radios will be used during this operation, including signals, contingency plans, and other relevant details.",
},
},
{
name: "frequencies",
type: "array",
admin: {
description:
"A list and description of each of the radio frequencies used for this mission.",
},
fields: [
{
type: "row",
fields: [
{
name: "frequency",
type: "number",
required: true,
},
{
name: "channel",
type: "number",
admin: {
description:
"Must be provided if the frequency used requires a separate radio channel.",
},
},
],
},
{
name: "netName",
type: "text",
admin: {
description:
'The name assigned to this frequency (+ channel.) Examples of names include "COMNET", "PLTNET", "CASNET", etc.',
},
required: true,
},
{
name: "freqUsage",
type: "textarea",
admin: {
description:
"An explanation of the expected usage and purpose of this radio net.",
},
required: true,
},
],
},
],
},
],
},
],
},
],
};

View file

@ -0,0 +1,136 @@
import { CollectionConfig } from "payload";
import { isAdmin } from "@/utils/access-control/isRole";
export const Technologies: CollectionConfig = {
slug: "technologies",
admin: {
group: "Intelligence",
useAsTitle: "name",
},
access: {
create: isAdmin,
update: isAdmin,
delete: isAdmin,
read: isAdmin,
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "type",
type: "select",
options: [
{
label: "Asset",
value: "asset",
},
{
label: "Upgrade",
value: "upgrade",
},
],
admin: {
description:
'An "Asset" technology will unlock a new item, vehicle, gear piece, structure, etc. An "Upgrade" technology will unlock an improvement to an Asset, like faster build speed, more storage capacity, etc.',
},
required: true,
defaultValue: "upgrade",
},
{
name: "summary",
type: "textarea",
required: true,
},
{
name: "description",
type: "richText",
},
{
name: "requirements",
type: "group",
fields: [
{
name: "intelligencePoints",
type: "number",
},
{
name: "prerequisiteTechnologies",
type: "array",
fields: [
{
type: "row",
fields: [
{
name: "requiresAnd",
type: "group",
label: "Requires (AND)",
admin: {
description:
"ALL of these technologies MUST be unlocked before this one can be.",
},
fields: [
{
name: "technology",
type: "relationship",
relationTo: "technologies",
hasMany: true,
},
],
},
{
name: "requiresOr",
type: "group",
label: "Requires (OR)",
admin: {
description:
"One or more of these technologies MUST be unlocked before this one can be.",
},
fields: [
{
name: "technology",
type: "relationship",
relationTo: "technologies",
hasMany: true,
},
],
},
],
},
],
},
],
},
{
name: "researchCosts",
type: "group",
fields: [
{
type: "row",
fields: [
{
name: "minimumResearchDuration",
type: "number",
admin: {
description:
"The minimum amount of time in minutes that it takes to research this technology. If no maximum duration is provided, this technology will be researched in an amount of time exactly equal to this number.",
},
required: true,
defaultValue: 0,
},
{
name: "maximumResearchDuration",
type: "number",
admin: {
description:
"The maximum amount of time in minutes that it takes to research this technology.",
},
},
],
},
],
},
],
};

View file

@ -0,0 +1,495 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Assets: CollectionConfig = {
slug: "assets",
admin: {
group: "Logistics",
useAsTitle: "name",
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
type: "tabs",
tabs: [
{
label: "Real Data",
description:
"In-game technical data for this asset, like its config class name, the mod it belongs to, a preview thumbnail image, etc.",
fields: [
{
name: "className",
type: "text",
required: true,
defaultValue: "",
},
{
name: "assetType",
type: "select",
required: true,
options: [
{
label: "Weapon",
value: "weapon",
},
{
label: "Magazine",
value: "magazine",
},
{
label: "Optic",
value: "optic",
},
{
label: "Muzzle",
value: "muzzle",
},
{
label: "Bipod",
value: "bipod",
},
{
label: "Uniform",
value: "uniform",
},
{
label: "Vest",
value: "vest",
},
{
label: "Backpack",
value: "backpack",
},
{
label: "Helmet",
value: "helmet",
},
{
label: "Facewear",
value: "facewear",
},
{
label: "NVG",
value: "nvg",
},
{
label: "Radio",
value: "radio",
},
{
label: "Medical",
value: "medical",
},
{
label: "Tool",
value: "tool",
},
{
label: "Explosive",
value: "explosive",
},
{
label: "Miscellaneous",
value: "miscellaneous",
},
],
defaultValue: "weapon",
},
{
name: "Weapon Configuration",
type: "group",
admin: {
condition: (data, siblingData) => siblingData.assetType === "weapon",
},
fields: [
{
name: "slotType",
type: "select",
options: [
{
label: "Primary",
value: "primary",
},
{
label: "Secondary",
value: "secondary",
},
{
label: "Launcher",
value: "launcher",
},
],
},
{
name: "compatibleMagazines",
type: "relationship",
relationTo: "assets",
hasMany: true,
filterOptions: {
assetType: {
equals: "magazine",
},
},
},
{
name: "compatibleAttachments",
type: "relationship",
relationTo: "assets",
hasMany: true,
filterOptions: {
assetType: {
in: "optic,muzzle,bipod",
},
},
},
],
},
{
name: "Nightvision Configuration",
type: "group",
admin: {
condition: (data, siblingData) => siblingData.assetType === "nvg",
},
fields: [
{
name: "nvgGeneration",
type: "select",
label: "NVG Generation",
options: [
{
label: "Gen 1",
value: "gen1",
},
{
label: "Gen 2",
value: "gen2",
},
{
label: "Gen 3",
value: "gen3",
},
],
},
{
name: "nvgHasThermals",
type: "checkbox",
label: "NVG Thermal Capability",
admin: {
description:
"Whether or not these night-vision goggles possess a thermal vision mode.",
},
},
],
},
{
name: "Container Configuration",
type: "group",
admin: {
condition: (data, siblingData) =>
["uniform", "vest", "backpack"].includes(siblingData.assetType),
},
fields: [
{
name: "capacityKg",
type: "number",
label: "Storage Capacity (kg.)",
},
{
name: "weightKg",
type: "number",
label: "Weight (kg.)",
},
],
},
{
name: "Magazine Configuration",
type: "group",
admin: {
condition: (data, siblingData) => siblingData.assetType === "magazine",
},
fields: [
{
name: "roundCapacity",
type: "number",
},
{
name: "ammoType",
type: "text",
},
{
name: "greenmagCompatible",
type: "checkbox",
admin: {
description:
"Whether or not this magazine is compatible with the GreenMag mod.",
},
},
],
},
{
name: "Asset Metadata",
type: "group",
fields: [
{
name: "mod",
type: "text",
},
{
name: "thumbnail",
type: "upload",
relationTo: "media",
},
{
name: "gameDescription",
type: "textarea",
label: "In-Game Description",
},
],
},
],
},
{
label: "Virtual Data",
description:
"Virtual data for this asset, like the factions that use it, its rarity and perceived value, trading and market flags, resources for crafting, etc.",
fields: [
{
name: "tradeable",
type: "checkbox",
admin: {
description:
"Whether or not this asset can be traded on the market. If unchecked, the asset can never be bought or sold, only crafted or collected.",
},
},
{
name: "marketTrading",
type: "group",
label: "Market & Trading",
admin: {
condition: (data, siblingData) => siblingData.tradeable,
},
fields: [
{
name: "Rarity",
type: "select",
admin: {
description:
"The rarity of this item in the world of the active campaign. Rarity will be accounted for in market valuation and trading calculations.",
},
options: [
{
label: "Basic",
value: "basic",
},
{
label: "Common",
value: "common",
},
{
label: "Uncommon",
value: "uncommon",
},
{
label: "Rare",
value: "rare",
},
{
label: "Very Rare",
value: "veryRare",
},
{
label: "Epic",
value: "epic",
},
{
label: "Legendary",
value: "legendary",
},
{
label: "Unique",
value: "unique",
},
],
},
{
name: "perceivedValue",
type: "group",
admin: {
description:
'The asset\'s "perceived value" when considered in market and trading calculations. Note that the "base" values are the "resting" valuations. Later calculations will increase and decrease the valuation based on this resting valuation, ultimately tending to remain within some percentage margin of this base price. Always and only considers the primary campaign currency in regard to price.',
},
fields: [
{
type: "row",
fields: [
{
name: "baseBuyPrice",
type: "number",
},
{
name: "baseSellPrice",
type: "number",
},
],
},
],
},
],
},
{
name: "crafting",
type: "group",
fields: [
{
name: "craftable",
type: "checkbox",
},
{
name: "blueprintRequired",
type: "checkbox",
admin: {
description:
"This asset requires a blueprint to be crafted, otherwise it can only be obtained from scavenging and/or trading.",
},
},
{
name: "technologyRequired",
type: "relationship",
relationTo: "technologies",
hasMany: true,
admin: {
description:
"One or more technologies required before this weapon can be crafted.",
},
},
{
name: "technologyRequiredToUse",
type: "checkbox",
admin: {
description:
"If checked, this asset requires the above technologies or else cannot be used at all.",
},
},
{
name: "craftingData",
type: "group",
fields: [
{
name: "inputs",
type: "array",
fields: [
{
name: "resource",
type: "relationship",
relationTo: "resources",
hasMany: true,
required: true,
},
{
name: "quantity",
type: "number",
},
{
name: "lended",
type: "checkbox",
admin: {
description:
'If checked, this resource is "lended" to the crafting of this asset. Upon the crafting\'s completion, this resource is refunded to the unit.',
},
},
],
},
{
name: "requiresStructure",
type: "relationship",
relationTo: "structures",
hasMany: true,
},
{
name: "craftingTimePerUnit",
type: "number",
label: "Crafting Time per Unit (minutes)",
required: true,
defaultValue: 1,
},
{
name: "byproducts",
type: "array",
admin: {
description:
"Byproducts of the crafting process. For each unit crafted of this Asset, these byproducts are also created and added to the unit's resources.",
},
fields: [
{
name: "resource",
type: "relationship",
relationTo: "resources",
hasMany: true,
required: true,
},
{
name: "quantity",
type: "number",
},
],
},
{
name: "batchSize",
type: "number",
admin: {
description:
"An optional batch size. If provided, this number of this asset will be created upon crafting completion.",
},
required: true,
defaultValue: 1,
},
],
},
],
},
{
name: "relatedAssets",
type: "relationship",
relationTo: "assets",
hasMany: true,
admin: {
description:
"Suggested complements to this asset (for example, if the asset was a Bandage, some related assets could include an IV, blood bag, epinephrine, etc.)",
},
},
],
},
{
label: "Auditing",
description: "Auditing and moderation for this Asset.",
admin: {
condition: ({ user }) => isDeveloper({ req: { user: user } } as never),
},
access: {
read: isDeveloper,
create: isDeveloper,
update: isDeveloper,
},
fields: [
{
name: "isLive",
type: "checkbox",
admin: {
description:
'If checked, this item is approved and considered "live" (i.e. it will be considered in calculations and made available in-game.)',
},
},
{
name: "notes",
type: "richText",
},
],
},
],
},
],
};

View file

@ -0,0 +1,260 @@
import { CollectionConfig, PayloadRequest } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
const onlyIfNotPrimaryCurrency = async ({ req }: { req: PayloadRequest }) => {
if (isDeveloper({ req })) {
return true;
}
try {
const gameRules = await req.payload.findGlobal({
slug: "game-rules",
depth: 0,
});
const primaryCurrencyId =
typeof gameRules.mainCurrency === "object"
? gameRules.mainCurrency?.id
: gameRules.mainCurrency;
if (primaryCurrencyId) {
return {
id: {
not_equals: primaryCurrencyId,
},
};
}
} catch (_) {
// Fail silently and allow update if global rules cannot be fetched
}
return true;
};
export const Resources: CollectionConfig = {
slug: "resources",
admin: {
useAsTitle: "name",
group: "Logistics",
},
access: {
update: onlyIfNotPrimaryCurrency,
delete: onlyIfNotPrimaryCurrency,
},
fields: [
{
name: "NoEditWarning",
type: "ui",
admin: {
condition: (data) => data.showWarning,
components: {
Field: {
path: "@/components/static/WarningMessage",
serverProps: {
title: "This item is locked.",
message:
"This item is considered an essential application component and can only be edited by a developer.",
},
},
},
disableListColumn: true,
},
},
{
label: "Identity",
type: "group",
fields: [
{
name: "showWarning",
type: "checkbox",
virtual: true,
admin: {
hidden: true,
readOnly: true,
disableListColumn: true,
},
hooks: {
afterRead: [
async ({ data, req }) => {
const gameRules = await req.payload.findGlobal({
slug: "game-rules",
depth: 0,
});
const primaryCurrencyId =
typeof gameRules.mainCurrency === "object"
? gameRules.mainCurrency?.id
: gameRules.mainCurrency;
if (primaryCurrencyId) {
return data?.id === primaryCurrencyId;
}
},
],
},
},
{
name: "name",
type: "text",
required: true,
admin: {
description: 'Human-readable name for resources (e.g., "Fuel").',
},
},
{
name: "codeName",
type: "text",
required: true,
admin: {
description: 'Internal ID for scripts (e.g., "res_fuel").',
},
},
{
name: "type",
type: "select",
required: true,
defaultValue: "physical",
options: [
{
label: "Physical",
value: "physical",
},
{
label: "Fluid",
value: "fluid",
},
{
label: "Energy",
value: "energy",
},
{
label: "Abstract",
value: "abstract",
},
],
},
{
name: "description",
type: "richText",
admin: {
description:
"A description of the resource, including where it comes from, how it is used, and any lore-related details.",
},
},
{
name: "icon",
type: "upload",
relationTo: "media",
admin: {
description:
"While not required, an icon is absolutely recommended and will affect the way this resource is displayed in the application UI.",
},
},
{
name: "color",
type: "text",
admin: {
description:
"A HEX color code used by this resource in the application UI (e.g., progress bars, text highlighting, etc.)",
},
},
],
},
{
label: "Economy",
type: "group",
fields: [
{
name: "baseValue",
type: "number",
required: true,
defaultValue: 1,
admin: {
description:
'The inherent "gold standard" value of one (1) unit of this resource. Used for calculating exchange rates and total asset worth.',
},
},
{
name: "tradeable",
type: "checkbox",
label: "Is Tradeable?",
admin: {
description: "Can this be bought and/or sold on the market?",
},
},
{
name: "rarity",
type: "select",
required: true,
defaultValue: "common",
options: [
{
label: "Common",
value: "common",
},
{
label: "Uncommon",
value: "uncommon",
},
{
label: "Rare",
value: "rare",
},
{
label: "Epic",
value: "epic",
},
{
label: "Legendary",
value: "legendary",
},
{
label: "Exotic",
value: "exotic",
},
{
label: "Unique",
value: "unique",
},
],
},
],
},
{
label: "Measurement & Storage",
type: "group",
fields: [
{
name: "unitOfMeasure",
type: "text",
required: true,
admin: {
description:
"The unit of measure for this resource in singular form (no plurals, e.g., liter, kg, point, unit, megawatt, person, etc.)",
},
},
{
name: "massPerUnit",
type: "number",
required: true,
defaultValue: 0,
admin: {
description:
'The weight of one (1) unit (can be "0" for abstract resources like "Intel", "Labor", etc.) Critical for logistics transport calculations.',
},
},
{
name: "stackSize",
type: "number",
defaultValue: 1,
admin: {
description:
"Maximum amount that fits in a single standard cargo slot (if applicable.)",
},
},
],
},
],
};

View file

@ -0,0 +1,396 @@
import { CollectionConfig } from "payload";
export const Structures: CollectionConfig = {
slug: "structures",
admin: {
useAsTitle: "name",
group: "Logistics",
},
fields: [
{
label: "Identity & Visuals",
type: "group",
fields: [
{
name: "name",
type: "text",
required: true,
admin: {
description: 'The display name (e.g., "Forward Operating Base").',
},
},
{
name: "codeName",
type: "text",
required: true,
admin: {
description: 'Internal ID for scripts (e.g., "fob_alpha_tier1").',
},
},
{
name: "description",
type: "richText",
required: true,
admin: {
description:
"A detailed description of the structure, including details like tactical advantages, resource production, and/or world lore regarding this structure.",
},
},
{
name: "image",
type: "upload",
relationTo: "media",
admin: {
description:
"An image depicting the structure. While not required, it is highly recommended as the existence of an image will affect the layout of the structure's page on the front-end.",
},
},
{
name: "category",
type: "select",
required: true,
options: [
{
label: "Logistics",
value: "logistics",
},
{
label: "Military",
value: "military",
},
{
label: "Civilian",
value: "civilian",
},
],
},
],
},
{
label: "Construction & Progression",
type: "group",
fields: [
{
name: "materials",
type: "array",
required: true,
fields: [
{
name: "resource",
type: "relationship",
relationTo: "resources",
required: true,
},
{
name: "amount",
type: "number",
},
],
},
{
name: "constructionTime",
type: "number",
required: true,
label: "Build Time (in game ticks)",
admin: {
description: "The number of ticks it takes to complete construction of this structure.",
},
},
{
name: "requiredTech",
type: "relationship",
relationTo: "technologies",
hasMany: true,
label: "Required Technology",
admin: {
description: "What research must be done before this can be built.",
},
},
{
name: "requiresUpgrade",
type: "checkbox",
admin: {
description:
"If true, this structure can only be constructed as the result of an upgrade to an existing structure. If false, this structure can be constructed independently of any other structure's upgrade tree, even if another structure can upgrade into this one.",
},
},
{
name: "upgradesInto",
type: "relationship",
relationTo: "structures",
admin: {
description:
'When this structure is upgraded, it becomes this type of structure. Note that the costs of the new structure apply - if this field is set, the new structure\'s build costs will still be consumed when this building is upgraded. To make it cheaper to upgrade, lower the "Upgrade Cost Multiplier".',
},
},
{
name: "upgradeCostMultiplier",
type: "number",
defaultValue: 1,
admin: {
description:
"The multiplier applied to the base build cost of the structure being upgraded into. A value of one (1) means the cost will exactly equal the structure's normal build cost, while a value of zero (0) would make the upgrade entirely free, only taking up construction time instead.",
},
},
{
label: "Placement Rules",
type: "group",
fields: [
{
name: "terrainType",
type: "select",
required: true,
options: [
{
label: "Land",
value: "land",
},
{
label: "Water",
value: "water",
},
],
},
{
name: "resourcesInRange",
type: "array",
admin: {
description:
"What resources, if any, must be within a certain distance of this structure on placement.",
},
fields: [
{
name: "resource",
type: "relationship",
relationTo: "resources",
},
{
name: "range",
type: "number",
},
],
},
{
name: "structuresInRange",
type: "array",
admin: {
description:
"What structures, if any, must be within a certain distance of this structure on placement.",
},
fields: [
{
name: "structure",
type: "relationship",
relationTo: "structures",
},
{
name: "range",
type: "number",
},
],
},
],
},
],
},
{
label: "Economy & Upkeep",
type: "group",
fields: [
{
name: "upkeep",
type: "array",
admin: {
description: "Recurring costs to keep this structure online.",
},
fields: [
{
name: "resource",
type: "relationship",
relationTo: "resources",
},
{
name: "amount",
type: "number",
admin: {
description: 'How much of this resource is consumed per "game tick" (every hour.)',
},
},
],
},
{
name: "sellPrice",
type: "number",
admin: {
description:
"A base price that this structure could sell for when fully operational. Leaving this empty will make this structure unsellable.",
},
},
],
},
{
label: "Production & Function",
type: "group",
fields: [
{
name: "functionType",
type: "select",
admin: {
description:
"The app will use this type to determine what processes should run for this structure type.",
},
options: [
{
label: "Producer",
value: "producer",
},
{
label: "Storage",
value: "storage",
},
{
label: "Sensor",
value: "sensor",
},
{
label: "Converter",
value: "converter",
},
{
label: "Defense",
value: "defense",
},
{
label: "Miscellaneous",
value: "miscellaneous",
},
],
},
{
label: "Production Capabilities",
type: "group",
fields: [
{
name: "canProduce",
type: "relationship",
relationTo: ["assets", "vehicles", "resources"],
hasMany: true,
},
{
name: "autoGeneratedResources",
type: "array",
fields: [
{
name: "resource",
type: "relationship",
relationTo: "resources",
},
{
name: "amount",
type: "number",
},
{
name: "frequency",
type: "text",
},
],
},
],
},
{
label: "Storage",
type: "group",
fields: [
{
name: "maxCapacityMass",
type: "number",
label: "Max Capacity (mass)",
admin: {
description:
"The maximum storage capacity of this structure as mass (retrieved from the mass defined for every asset stored here.)",
},
},
{
label: "Storage Slot Dimensions",
type: "group",
fields: [
{
name: "xSize",
type: "number",
label: "Columns (X)",
},
{
name: "ySize",
type: "number",
label: "Rows (Y)",
},
],
},
{
name: "allowedStorage",
type: "array",
fields: [
{
name: "item",
type: "relationship",
relationTo: ["assets", "vehicles", "resources"],
},
{
name: "amount",
type: "number",
admin: {
description: "How many of this asset, vehicle, or resource can be stored here.",
},
},
],
},
{
name: "prohibitedStorage",
type: "array",
admin: {
description:
"Any items from this list can never be stored here, even if they meet all other criteria.",
},
fields: [
{
name: "item",
type: "relationship",
relationTo: ["assets", "vehicles", "resources"],
},
],
},
],
},
],
},
{
label: "Physical Properties",
type: "group",
fields: [
{
label: "Durability",
type: "group",
fields: [
{
name: "maxHealth",
type: "number",
required: true,
},
{
name: "repairSpeed",
type: "number",
admin: {
description:
"So long as the structure meets its operational requirements, this is the amount of health restored to the structure every game tick.",
},
},
],
},
],
},
],
};

View file

@ -0,0 +1,343 @@
import { CollectionConfig } from "payload";
export const Vehicles: CollectionConfig = {
slug: "vehicles",
admin: {
useAsTitle: "name",
group: "Logistics",
},
fields: [
{
label: "Identity",
type: "group",
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "classname",
type: "text",
admin: {
description:
"If not provided, this vehicle will ONLY be available as a virtual asset (i.e., cannot be used in Arma.)",
},
},
{
name: "Description",
type: "richText",
admin: {
description:
"A detailed description of the vehicle, including details like tactical usage, mechanical details, and/or world lore regarding this vehicle.",
},
},
{
name: "image",
type: "upload",
relationTo: "media",
},
{
name: "category",
type: "select",
hasMany: true,
options: [
{
label: "Civilian",
value: "civilian",
},
{
label: "Utility",
value: "utility",
},
{
label: "Transport",
value: "transport",
},
{
label: "Weaponized",
value: "weaponized",
},
{
label: "Military",
value: "military",
},
],
},
],
},
{
label: "Construction",
type: "group",
fields: [
{
name: "materials",
type: "array",
fields: [
{
name: "resource",
type: "relationship",
required: true,
relationTo: "resources",
},
{
name: "amount",
type: "number",
},
],
},
{
name: "constructionTime",
type: "number",
admin: {
description: "The number of ticks it takes to complete construction of this vehicle.",
},
},
{
name: "requiredTech",
type: "relationship",
relationTo: "technologies",
hasMany: true,
label: "Required Technologies",
admin: {
description: "What research must be done before this can be built.",
},
},
{
name: "requiredStructure",
type: "join",
collection: "structures",
on: "canProduce",
},
],
},
{
label: "Scrapping & Maintenance",
type: "group",
fields: [
{
name: "scrapReturns",
type: "array",
label: "Materials Returned when Scrapped",
fields: [
{
name: "resource",
type: "relationship",
required: true,
relationTo: "resources",
},
{
name: "amount",
type: "number",
defaultValue: 1,
},
{
name: "chance",
type: "number",
min: 0,
max: 100,
defaultValue: 100,
admin: {
description:
"An optional percent (%) chance to recover this specific resource (between 0 and 100).",
},
},
],
},
{
name: "upkeepCosts",
type: "array",
admin: {
description: "Recurring costs to keep this vehicle operational.",
},
fields: [
{
name: "resource",
type: "relationship",
required: true,
relationTo: "resources",
},
{
name: "amount",
type: "number",
defaultValue: 1,
admin: {
description: "How much of this resource is consumed per tick.",
},
},
],
},
],
},
{
label: "Logistics & Grid Storage",
type: "group",
fields: [
{
label: "Storage Grid Dimensions",
type: "group",
fields: [
{
name: "width",
type: "number",
},
{
name: "height",
type: "number",
},
],
},
{
label: "Cargo Capacity",
type: "group",
fields: [
{
name: "maxMass",
label: "Max Weight (kg.)",
type: "number",
},
{
name: "aceCargoSlots",
type: "number",
label: "ACE Cargo Slots",
admin: {
description: "The number of ACE cargo slots this vehicle has by default.",
},
},
{
name: "allowedVirtualCargo",
type: "array",
admin: {
description:
"The cargo this vehicle is permitted to carry if used virtually (e.g., during deliveries, moving troops, etc.)",
},
fields: [
{
name: "item",
type: "relationship",
relationTo: ["assets", "vehicles", "resources"],
required: true,
},
{
name: "amount",
type: "number",
defaultValue: 1,
},
],
},
],
},
],
},
{
label: "Combat & Performance",
type: "group",
fields: [
{
label: "Durability",
type: "group",
admin: {
description:
"These attributes only apply to the virtual vehicle and are not required nor expected to match the in-game version's actual attributes.",
},
fields: [
{
name: "maxHealth",
type: "number",
},
{
name: "armorRating",
type: "select",
options: [
{
label: "None",
value: "none",
},
{
label: "Light",
value: "light",
},
{
label: "Medium",
value: "medium",
},
{
label: "Heavy",
value: "heavy",
},
{
label: "Composite",
value: "composite",
},
],
},
],
},
{
label: "Speed & Agility",
type: "group",
fields: [
{
name: "maxSpeedOnRoad",
type: "number",
},
{
name: "maxSpeedOffRoad",
type: "number",
},
{
name: "maxAirspeed",
type: "number",
},
{
name: "maxKnots",
type: "number",
},
],
},
{
name: "pylons",
label: "Pylons & Mounted Weapons",
type: "array",
fields: [
{
name: "pylonName",
type: "text",
required: true,
},
{
name: "pylonType",
type: "select",
required: true,
defaultValue: "mountedWeapon",
options: [
{
label: "Mounted Weapon",
value: "mountedWeapon",
},
{
label: "Pylon",
value: "pylon",
},
],
},
{
name: "amount",
type: "number",
defaultValue: 1,
},
{
name: "ammoType",
type: "relationship",
relationTo: "assets",
},
],
},
],
},
],
};

View file

@ -0,0 +1,55 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const MissionFiles: CollectionConfig = {
slug: "mission-files",
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
},
admin: {
group: "Server",
},
fields: [
{
name: "missionFileName",
type: "text",
required: true,
},
{
name: "author",
type: "relationship",
relationTo: "users",
required: true,
},
],
upload: {
mimeTypes: ["application/octet-stream"],
},
hooks: {
beforeValidate: [
async ({ data, req }) => {
// Enforce .pbo extension on uploaded filename
// For direct file uploads, req.files?.file?.name is typically present
const file = (req as any)?.files?.file;
const name: string | undefined =
file?.name ??
(typeof data?.filename === "string" ? data.filename : undefined) ??
(typeof data?.file === "string" ? data.file : undefined);
if (!name || !name.toLowerCase().endsWith(".pbo")) {
throw new Error("Only .pbo files are allowed.");
}
// Optional: store clean filename (without path)
if (data) {
data.missionFileName = name;
}
return data;
},
],
},
};

View file

@ -0,0 +1,34 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const ModLists: CollectionConfig = {
slug: "mod-lists",
admin: {
group: "Server",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
},
fields: [
{
name: "modlistName",
type: "text",
admin: {
description: "A friendly name for the mod list.",
},
required: true,
},
{
name: "author",
type: "relationship",
relationTo: "users",
required: true,
},
],
upload: {
mimeTypes: ["text/html"],
},
};

View file

@ -0,0 +1,95 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Assignments: CollectionConfig = {
slug: "assignments",
admin: {
group: "Users",
useAsTitle: "name",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "type",
type: "select",
options: [
{
label: "Division",
value: "division",
},
{
label: "Squad",
value: "squad",
},
{
label: "Team",
value: "team",
},
{
label: "Special",
value: "special",
},
],
required: true,
},
{
name: "callsign",
type: "text",
},
{
name: "description",
type: "richText",
},
{
name: "leader",
type: "relationship",
relationTo: "users",
admin: {
description:
"The leader represents the individual responsible for this assignment and its members. The leader automatically counts as a member when this assignment is queried.",
},
},
{
name: "commandElement",
type: "join",
collection: "assignments",
on: "subAssignments",
admin: {
allowCreate: false,
condition: (data) => {
const hasCommandElement = data?.commandElement?.docs;
return Array.isArray(hasCommandElement)
? hasCommandElement.length > 0
: Boolean(hasCommandElement);
},
},
},
{
name: "subAssignments",
type: "relationship",
relationTo: "assignments",
hasMany: true,
admin: {
description: "Add other teams as members of this team/assignment.",
},
},
{
name: "members",
type: "relationship",
relationTo: "users",
hasMany: true,
admin: {
description: "Add individuals as members of this team/assignment.",
},
},
],
};

View file

@ -1,12 +1,12 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isAdmin } from "@/utils/access-control/isRole"; import { isDeveloper } from "@/utils/access-control/isRole";
export const Awards: CollectionConfig = { export const Awards: CollectionConfig = {
slug: "awards", slug: "awards",
access: { access: {
create: isAdmin, create: isDeveloper,
update: isAdmin, update: isDeveloper,
delete: isAdmin, delete: isDeveloper,
}, },
admin: { admin: {
useAsTitle: "name", useAsTitle: "name",

View file

@ -1,5 +1,7 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { Award } from "@/payload-types"; import { Award } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Profiles: CollectionConfig = { export const Profiles: CollectionConfig = {
slug: "profiles", slug: "profiles",
@ -8,16 +10,24 @@ export const Profiles: CollectionConfig = {
group: "Users", group: "Users",
defaultColumns: ["profileTitle", "user", "rank"], defaultColumns: ["profileTitle", "user", "rank"],
}, },
access: {
read: (acl) => hasRoles(["user"], acl.req.user),
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
},
fields: [ fields: [
{ {
name: "user", name: "user",
type: "relationship", type: "relationship",
relationTo: "users", relationTo: "users",
required: true,
}, },
{ {
name: "rank", name: "rank",
type: "relationship", type: "relationship",
relationTo: "ranks", relationTo: "ranks",
required: true,
}, },
{ {
name: "profileTitle", name: "profileTitle",
@ -78,6 +88,25 @@ export const Profiles: CollectionConfig = {
xpAcc += awards.reduce((prev, cur) => prev + (cur.experiencePoints ?? 0), 0); xpAcc += awards.reduce((prev, cur) => prev + (cur.experiencePoints ?? 0), 0);
}); });
Object.entries(
(siblingData as { scoreboard: Record<string, number | null> }).scoreboard,
).forEach(([k, v]) => {
if (!v) return;
switch (k) {
case "vehicleKills":
xpAcc += v * 100;
break;
case "armorKills":
case "airKills":
xpAcc += v * 500;
break;
case "infantryKills":
default:
xpAcc += v * 10;
break;
}
});
return xpAcc; return xpAcc;
}, },
], ],
@ -92,6 +121,40 @@ export const Profiles: CollectionConfig = {
appearance: "drawer", appearance: "drawer",
}, },
}, },
{
name: "scoreboard",
type: "group",
admin: {
readOnly: true,
},
fields: [
{
name: "infantryKills",
type: "number",
defaultValue: 0,
},
{
name: "vehicleKills",
type: "number",
defaultValue: 0,
},
{
name: "armorKills",
type: "number",
defaultValue: 0,
},
{
name: "airKills",
type: "number",
defaultValue: 0,
},
{
name: "deaths",
type: "number",
defaultValue: 0,
},
],
},
], ],
}, },
], ],

View file

@ -0,0 +1,22 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Qualifications: CollectionConfig = {
slug: "qualifications",
admin: {
group: "Users",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
},
fields: [
{
name: "name",
type: "text",
unique: true,
required: true,
},
],
};

View file

@ -1,16 +1,16 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isAdmin } from "@/utils/access-control/isRole"; import { isDeveloper } from "@/utils/access-control/isRole";
export const Ranks: CollectionConfig = { export const Ranks: CollectionConfig = {
slug: "ranks", slug: "ranks",
access: { access: {
create: isAdmin, create: isDeveloper,
update: isAdmin, update: isDeveloper,
delete: isAdmin, delete: isDeveloper,
}, },
admin: { admin: {
useAsTitle: "name", useAsTitle: "name",
group: "Users" group: "Users",
}, },
orderable: true, orderable: true,
fields: [ fields: [

View file

@ -1,14 +1,29 @@
import type { CollectionConfig } from "payload"; import type { CollectionConfig } from "payload";
import { isAdmin } from "@/utils/access-control/isRole"; import { isAdmin, isDeveloper } from "@/utils/access-control/isRole";
export const Users: CollectionConfig = { export const Users: CollectionConfig = {
slug: "users", slug: "users",
access: { access: {
admin: isAdmin, admin: isAdmin,
unlock: isAdmin, unlock: isDeveloper,
create: isDeveloper,
update: ({ req, data }) => {
const isEditingDeveloper = data?.roles?.includes("developer");
if (isEditingDeveloper) {
return isDeveloper({ req });
}
return isAdmin({ req });
},
delete: ({ req, data }) => {
const isEditingDeveloper = data?.roles?.includes("developer");
if (isEditingDeveloper) {
return isDeveloper({ req });
}
return isAdmin({ req });
},
}, },
admin: { admin: {
useAsTitle: "username", useAsTitle: "payloadDisplayName",
group: "Users", group: "Users",
}, },
auth: { auth: {
@ -35,11 +50,39 @@ export const Users: CollectionConfig = {
{ {
name: "displayName", name: "displayName",
type: "text", type: "text",
label: "In-Game Name",
required: true,
},
{
name: "payloadDisplayName",
type: "text",
label: "Display Name",
admin: {
hidden: true,
readOnly: true,
},
hooks: {
beforeChange: [
({ data }) => {
// console.log(data);
return `${data!.displayName ? data!.displayName + " " : ""}(${data!.username})`;
},
],
},
},
{
name: "steamId",
type: "text",
label: "Steam ID 64",
required: true, required: true,
}, },
{ {
name: "roles", name: "roles",
type: "select", type: "select",
access: {
create: isDeveloper,
update: isDeveloper,
},
hasMany: true, hasMany: true,
options: [ options: [
{ {
@ -59,6 +102,13 @@ export const Users: CollectionConfig = {
value: "developer", value: "developer",
}, },
], ],
/*filterOptions: ({ options, data, req }) => {
return !isDeveloper({ req })
? options.filter(
(option) => (typeof option === "string" ? options : !["developer", "admin"].includes(option.value)),
)
: options;
},*/
}, },
], ],
}; };

View file

@ -0,0 +1,23 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const LogisticsEvents: CollectionConfig = {
slug: "logistics-events",
admin: {
group: "World",
useAsTitle: "name",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
},
fields: [
{
name: "name",
type: "text",
required: true,
},
],
};

View file

@ -0,0 +1,28 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Maps: CollectionConfig = {
slug: "maps",
admin: {
group: "World",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
},
fields: [
{
name: "name",
type: "text",
required: true,
unique: true,
},
{
name: "fileName",
type: "text",
unique: true,
},
],
};

View file

@ -0,0 +1,44 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const NarrativeEvents: CollectionConfig = {
slug: "narrative-events",
admin: {
group: "World",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "summary",
type: "textarea",
required: true,
},
{
type: "tabs",
tabs: [
{
label: "Event Setup",
description:
"Establish the basic requirements and conditions needed for this event to occur.",
fields: [],
},
{
label: "Narrative Graph",
description:
"Write the event storyline here, including choices, conditions, and outcomes!",
fields: [],
},
],
},
],
};

View file

@ -0,0 +1,15 @@
import iconSvg from "@/assets/images/icon.svg";
import Image from "next/image";
export default function AppIcon() {
return (
<div>
<Image
src={iconSvg.src}
alt="Polaris Task Force"
width={iconSvg.width}
height={iconSvg.height}
/>
</div>
);
}

View file

@ -0,0 +1,15 @@
import logoImage from "@/assets/images/logo.webp";
import Image from "next/image";
export default function AppLogo() {
return (
<div>
<Image
src={logoImage.src}
alt="Polaris Task Force"
width={logoImage.width}
height={logoImage.height}
/>
</div>
);
}

View file

@ -0,0 +1,72 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { TriangleAlert } from "lucide-react";
export const BeforeDashboardText = () => {
return (
<div className="mb-5 space-y-2">
<div className="space-y-1">
<h1>Dashboard</h1>
<p className="text-lg font-semibold text-white/75">
You are currently viewing the application&apos;s backend configuration and management
screen.
</p>
</div>
<div className="my-5">
<Alert className="bg-orange-500/25 border-orange-500">
<TriangleAlert className="w-8! h-8!" />
<AlertTitle className="ml-5 text-2xl text-shadow-orange-900/50 text-shadow-sm">
This application is a work-in-progress!
</AlertTitle>
<AlertDescription className="ml-5 text-lg text-shadow-orange-900/50 text-shadow-sm">
<p>
As this application is under active development, features and configurations may
change significantly and without prior notice, including any modifications you make.{" "}
<strong>
Please be aware that data or work contributed may be lost during updates.
</strong>{" "}
Your patience and understanding are greatly appreciated.
</p>
</AlertDescription>
</Alert>
</div>
<div>
<p>
If you are new to this panel, please keep in mind several important distinctions between
this screen and the application&apos;s regular front-end:
</p>
<ol>
<li>
The <b>front-end</b> (<i>not</i> this panel) is where the magic actually happens.
Queuing jobs, buying resources and assets, managing funds, signing up for missions, and
all other forms of user interaction take place there. If you want to perform these sorts
of operations, you should go there instead.
</li>
<li>
The <b>back-end</b> (this screen and its children) is the &quot;developer&quot; view,
where you can view and manage the <i>definitions</i> and <i>rules</i> of the things the
front-end interacts with. If you want to define the different types of structures,
create new assets and give them properties, create missions, and other app
&quot;development&quot; operations, this is where those things happen.
</li>
<li>
Before the campaign starts, this screen is where most work on the app will take place.
After the campaign has started, you mostly won&apos;t use this screen for the majority
of interactions, save for some exceptions (like mission building, lore writing, etc.)
</li>
</ol>
<p>
If you understand all of this, then please carry on with what you are doing! If you are
looking for a certain action and cannot find it here, you&apos;ll probably want to go
check the front-end to see if you can perform that action there instead.
</p>
<p className="font-bold">
Thank you for all of your help and patience with this app! It means the world to me. 💖
&mdash; Jason
</p>
</div>
<hr color="#666" />
</div>
);
};
export default BeforeDashboardText;

View file

@ -0,0 +1,14 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { LucideAlertTriangle } from "lucide-react";
export const WarningMessage = ({ title, message }: { title: string; message: string }) => {
return (
<Alert className="border-red-500">
<LucideAlertTriangle />
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{message}</AlertDescription>
</Alert>
);
};
export default WarningMessage;

View file

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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 "media" ALTER COLUMN "alt" DROP NOT NULL;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "media" ALTER COLUMN "alt" SET NOT NULL;`)
}

15
src/migrations/index.ts Normal file
View file

@ -0,0 +1,15 @@
import * as migration_20251206_065831 from './20251206_065831';
import * as migration_20251212_183607 from './20251212_183607';
export const migrations = [
{
up: migration_20251206_065831.up,
down: migration_20251206_065831.down,
name: '20251206_065831',
},
{
up: migration_20251212_183607.up,
down: migration_20251212_183607.down,
name: '20251212_183607'
},
];

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
// storage-adapter-import-placeholder // storage-adapter-import-placeholder
import { postgresAdapter } from "@payloadcms/db-postgres"; import { postgresAdapter } from "@payloadcms/db-postgres";
import { payloadCloudPlugin } from "@payloadcms/payload-cloud";
import { lexicalEditor } from "@payloadcms/richtext-lexical"; import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "path"; import path from "path";
import { buildConfig } from "payload"; import { buildConfig } from "payload";
@ -8,22 +7,86 @@ import { fileURLToPath } from "url";
import sharp from "sharp"; import sharp from "sharp";
import { Users } from "@/collections/users/Users"; import { Users } from "@/collections/users/Users";
import { Media } from "@/collections/Media";
import { Profiles } from "@/collections/users/Profiles";
import { Ranks } from "@/collections/users/Ranks";
import { Awards } from "@/collections/users/Awards"; import { Awards } from "@/collections/users/Awards";
import { Media } from "@/collections/Media";
import { Ranks } from "@/collections/users/Ranks";
import { Profiles } from "@/collections/users/Profiles";
import { Missions } from "@/collections/intelligence/Missions";
import { Maps } from "@/collections/world/Maps";
import { Campaigns } from "@/collections/intelligence/Campaigns";
import { Qualifications } from "@/collections/users/Qualifications";
import { MissionFiles } from "@/collections/server/MissionFiles";
import { ModLists } from "@/collections/server/ModLists";
import { Factions } from "@/collections/intelligence/Factions";
import { Assets } from "@/collections/logistics/Assets";
import { NarrativeEvents } from "@/collections/world/NarrativeEvents";
import { Resources } from "@/collections/logistics/Resources";
import { Vehicles } from "@/collections/logistics/Vehicles";
import { Structures } from "@/collections/logistics/Structures";
import { Assignments } from "@/collections/users/Assignments";
import { Technologies } from "@/collections/intelligence/Technologies";
import { GameRules } from "@/collections/game/GameRules";
const filename = fileURLToPath(import.meta.url); const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename); const dirname = path.dirname(filename);
export default buildConfig({ export default buildConfig({
// serverURL: "http://localhost:3000/",
admin: { admin: {
user: Users.slug, user: Users.slug,
importMap: { importMap: {
baseDir: path.resolve(dirname), baseDir: path.resolve(dirname),
}, },
meta: {
titleSuffix: "- Polaris Task Force",
icons: [
{
rel: "icon",
type: "image/png",
url: "/images/icon-favicon.png",
}, },
collections: [Users, Media, Ranks, Profiles, Awards], ],
},
components: {
beforeDashboard: ["@/components/static/BeforeDashboardText"],
graphics: {
Logo: "@/components/graphics/AppLogo",
Icon: "@/components/graphics/AppIcon",
},
},
},
globals: [GameRules],
collections: [
Media,
// Users
Users,
Ranks,
Profiles,
Awards,
Qualifications,
Assignments,
// Intel
Missions,
Campaigns,
Factions,
Technologies,
// Logistics
Assets,
Resources,
Vehicles,
Structures,
// World
Maps,
NarrativeEvents,
// Server
MissionFiles,
ModLists,
],
editor: lexicalEditor(), editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || "", secret: process.env.PAYLOAD_SECRET || "",
typescript: { typescript: {
@ -36,7 +99,7 @@ export default buildConfig({
}), }),
sharp, sharp,
plugins: [ plugins: [
payloadCloudPlugin(), // payloadCloudPlugin(),
// storage-adapter-placeholder // storage-adapter-placeholder
], ],
}); });

View file

@ -0,0 +1,10 @@
import { seedRanks } from "@/tools/seed/seedRanks";
import { seedUsers } from "@/tools/seed/seedUsers";
import { seedFactions } from "@/tools/seed/seedFactions";
// Users
await seedUsers();
await seedRanks();
// Intelligence
await seedFactions();

View file

@ -0,0 +1,78 @@
import { getPayload } from "payload";
import config from "@payload-config";
import { Faction } from "@/payload-types";
// These are the default factions as of the campaign "The World We Knew".
const DEFAULT_FACTIONS: Omit<Faction, "id" | "sizes" | "createdAt" | "deletedAt" | "updatedAt">[] =
[
{
name: "American Reclamation Project",
description: null,
playableFaction: true,
startingFaction: true,
type: "majorFaction",
},
{
name: "Eurasian Redevelopment Alliance",
description: null,
playableFaction: true,
startingFaction: true,
type: "majorFaction",
},
{
name: "Worldmark",
description: null,
playableFaction: true,
startingFaction: true,
type: "majorFaction",
},
{
name: "Inheritors",
description: null,
playableFaction: true,
startingFaction: true,
type: "minorFaction",
},
{
name: "Deepwell",
description: null,
playableFaction: true,
startingFaction: true,
type: "minorFaction",
},
{
name: "New Horizons",
description: null,
playableFaction: true,
startingFaction: true,
type: "minorFaction",
},
];
export const seedFactions = async () => {
const payload = await getPayload({ config });
payload.logger.info("Seeding faction data...");
payload.logger.warn("Deleting all existing factions...");
await payload.delete({
collection: "factions",
where: {
name: { exists: true },
},
});
payload.logger.info(`Creating ${DEFAULT_FACTIONS.length} new factions...`);
for (const faction of DEFAULT_FACTIONS) {
const result = await payload.create({
collection: "factions",
data: {
...faction,
},
});
payload.logger.info(`Faction '${result.name}' created.`);
}
};
await seedFactions();

View file

@ -0,0 +1,54 @@
import { getPayload } from "payload";
import config from "@payload-config";
export const seedProfiles = async () => {
const payload = await getPayload({ config });
payload.logger.info("Seeding profile data...");
payload.logger.warn("Deleting all existing profiles...");
await payload.delete({
collection: "profiles",
where: {
id: { exists: true },
},
});
payload.logger.info(`Creating initial sysadmin/developer profile...`);
try {
await payload.create({
collection: "profiles",
data: {
user: (
await payload.find({
collection: "users",
where: {
role: {
contains: "developer",
},
},
limit: 1,
})
).docs[0].id,
rank: (
await payload.find({
collection: "ranks",
where: {
name: {
equals: "First Lieutenant",
},
},
limit: 1,
})
).docs[0].id,
},
});
payload.logger.info(`Sysadmin/developer profile created.`);
} catch (_) {
payload.logger.error("Unable to create sysadmin/developer profile, aborting.");
return;
}
};
await seedProfiles();

View file

@ -0,0 +1,37 @@
import { getPayload } from "payload";
import config from "@payload-config";
export const seedUsers = async () => {
const payload = await getPayload({ config });
payload.logger.info("Seeding user data...");
payload.logger.warn("Deleting all existing users...");
await payload.delete({
collection: "users",
where: {
username: { exists: true },
},
});
payload.logger.info(`Creating initial sysadmin/developer user...`);
try {
const result = await payload.create({
collection: "users",
data: {
username: "dev",
password: "Test123",
displayName: "J. Fraley",
discordUsername: "Z8MB1E",
steamId: "76561198091303179",
},
});
payload.logger.info(`Sysadmin/developer user created.`);
} catch (err) {
payload.logger.error("Unable to create sysadmin/developer user, aborting.");
return;
}
};
await seedUsers();