Compare commits
No commits in common. "eecedece6b6f588c3cd645a961872a402ef534c3" and "43e2a151978a670d4bf4ca7850fb17ba61def072" have entirely different histories.
eecedece6b
...
43e2a15197
22 changed files with 37 additions and 48798 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.3",
|
"version": "0.2.2",
|
||||||
"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.",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,6 @@ import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
||||||
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
||||||
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
|
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
|
||||||
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
|
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
|
||||||
import { SideOpControls } from "@/components/frontend/intelligence/SideOpControls";
|
|
||||||
import { lexicalToText } from "@/lib/intelligence/briefingText";
|
|
||||||
import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog";
|
|
||||||
import {
|
import {
|
||||||
type AttendanceResponse,
|
type AttendanceResponse,
|
||||||
getMissionAttendance,
|
getMissionAttendance,
|
||||||
|
|
@ -84,7 +81,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
const map =
|
const map =
|
||||||
typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null;
|
typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null;
|
||||||
const campaign =
|
const campaign =
|
||||||
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
|
typeof mission.classification.campaign === "object"
|
||||||
? (mission.classification.campaign as Campaign)
|
? (mission.classification.campaign as Campaign)
|
||||||
: null;
|
: null;
|
||||||
const missionWithCoverImage = mission as MissionWithCoverImage;
|
const missionWithCoverImage = mission as MissionWithCoverImage;
|
||||||
|
|
@ -99,35 +96,32 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
const durationMins = mission.classification.estimatedDuration % 60;
|
const durationMins = mission.classification.estimatedDuration % 60;
|
||||||
const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`;
|
const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`;
|
||||||
|
|
||||||
// Fetch sibling missions in the same campaign (missions may have no campaign)
|
// Fetch sibling missions in the same campaign
|
||||||
const campaignId =
|
const campaignId =
|
||||||
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
|
typeof mission.classification.campaign === "object"
|
||||||
? (mission.classification.campaign as Campaign).id
|
? (mission.classification.campaign as Campaign).id
|
||||||
: mission.classification.campaign;
|
: mission.classification.campaign;
|
||||||
|
|
||||||
const siblingMissionsRes =
|
const siblingMissionsRes = await payload
|
||||||
campaignId != null
|
.find({
|
||||||
? await payload
|
collection: "missions",
|
||||||
.find({
|
where: {
|
||||||
collection: "missions",
|
and: [
|
||||||
where: {
|
{ "classification.campaign": { equals: campaignId } },
|
||||||
and: [
|
{ id: { not_equals: mission.id } },
|
||||||
{ "classification.campaign": { equals: campaignId } },
|
],
|
||||||
{ id: { not_equals: mission.id } },
|
},
|
||||||
],
|
sort: "-classification.startDateTime",
|
||||||
},
|
limit: 5,
|
||||||
sort: "-classification.startDateTime",
|
depth: 0,
|
||||||
limit: 5,
|
select: {
|
||||||
depth: 0,
|
name: true,
|
||||||
select: {
|
codeName: true,
|
||||||
name: true,
|
ownershipAndStatus: { status: true },
|
||||||
codeName: true,
|
classification: { startDateTime: true },
|
||||||
ownershipAndStatus: { status: true },
|
},
|
||||||
classification: { startDateTime: true },
|
})
|
||||||
},
|
.catch(() => ({ docs: [] }));
|
||||||
})
|
|
||||||
.catch(() => ({ docs: [] }))
|
|
||||||
: { docs: [] };
|
|
||||||
|
|
||||||
const siblingMissions = siblingMissionsRes.docs;
|
const siblingMissions = siblingMissionsRes.docs;
|
||||||
|
|
||||||
|
|
@ -140,63 +134,6 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
? await hasPermission(payload, currentUser, "missions:update")
|
? await hasPermission(payload, currentUser, "missions:update")
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const createdById =
|
|
||||||
typeof mission.createdBy === "object" ? mission.createdBy?.id : mission.createdBy;
|
|
||||||
const isSideOpOwner = currentUser != null && createdById === currentUser.id;
|
|
||||||
const canControlSideOp = canManage || isSideOpOwner;
|
|
||||||
|
|
||||||
let sideOpMaps: SideOpDialogOption[] = [];
|
|
||||||
let sideOpCampaigns: SideOpDialogOption[] = [];
|
|
||||||
let sideOpModLists: SideOpDialogOption[] = [];
|
|
||||||
if (canControlSideOp && currentUser) {
|
|
||||||
const [mapsRes, campaignsRes, modListsRes] = await Promise.all([
|
|
||||||
payload.find({
|
|
||||||
collection: "maps",
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
limit: 200,
|
|
||||||
sort: "name",
|
|
||||||
select: { name: true },
|
|
||||||
}),
|
|
||||||
payload.find({
|
|
||||||
collection: "campaigns",
|
|
||||||
where: { owner: { equals: currentUser.id } },
|
|
||||||
depth: 0,
|
|
||||||
limit: 100,
|
|
||||||
sort: "name",
|
|
||||||
select: { name: true },
|
|
||||||
}),
|
|
||||||
payload.find({
|
|
||||||
collection: "mod-lists",
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
limit: 200,
|
|
||||||
sort: "modlistName",
|
|
||||||
select: { modlistName: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
sideOpMaps = mapsRes.docs.map((m) => ({ id: m.id, name: m.name }));
|
|
||||||
sideOpCampaigns = campaignsRes.docs.map((c) => ({ id: c.id, name: c.name }));
|
|
||||||
sideOpModLists = modListsRes.docs.map((l) => ({ id: l.id, name: l.modlistName }));
|
|
||||||
const currentCampaignId =
|
|
||||||
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
|
|
||||||
? mission.classification.campaign.id
|
|
||||||
: mission.classification.campaign;
|
|
||||||
if (
|
|
||||||
currentCampaignId != null &&
|
|
||||||
!sideOpCampaigns.some((c) => c.id === currentCampaignId)
|
|
||||||
) {
|
|
||||||
sideOpCampaigns = [
|
|
||||||
...sideOpCampaigns,
|
|
||||||
{
|
|
||||||
id: currentCampaignId as number,
|
|
||||||
name: campaign ? `${campaign.name} (not yours)` : "Current campaign (not yours)",
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const authorOrZeusIds = [
|
const authorOrZeusIds = [
|
||||||
...(mission.ownershipAndStatus.authors ?? []),
|
...(mission.ownershipAndStatus.authors ?? []),
|
||||||
...(mission.ownershipAndStatus.zeus ?? []),
|
...(mission.ownershipAndStatus.zeus ?? []),
|
||||||
|
|
@ -397,62 +334,6 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
|
|
||||||
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
|
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{isSideOpOwner && mission.createdBy != null && (
|
|
||||||
<SideOpControls
|
|
||||||
initial={{
|
|
||||||
missionId: mission.id,
|
|
||||||
name: mission.name,
|
|
||||||
codeName: mission.codeName,
|
|
||||||
summary: mission.summary,
|
|
||||||
mapId:
|
|
||||||
typeof mission.classification.map === "object"
|
|
||||||
? mission.classification.map.id
|
|
||||||
: mission.classification.map,
|
|
||||||
missionType: mission.classification.missionType,
|
|
||||||
startDateTime: mission.classification.startDateTime,
|
|
||||||
estimatedDuration: mission.classification.estimatedDuration,
|
|
||||||
maxPlayers: mission.missionRoles?.maxPlayers ?? 32,
|
|
||||||
campaignId:
|
|
||||||
typeof mission.classification.campaign === "object" &&
|
|
||||||
mission.classification.campaign !== null
|
|
||||||
? mission.classification.campaign.id
|
|
||||||
: (mission.classification.campaign ?? null),
|
|
||||||
briefing: (mission.briefing ?? []).map((entry) => ({
|
|
||||||
sectionName: entry.sectionName,
|
|
||||||
details: lexicalToText(entry.details),
|
|
||||||
})),
|
|
||||||
missionObjectives: (mission.missionObjectives ?? []).map((obj) => ({
|
|
||||||
title: obj.title,
|
|
||||||
description: obj.description ?? "",
|
|
||||||
missionCritical: obj.missionCritical ?? true,
|
|
||||||
successOrFailConditions: obj.successOrFailConditions ?? "",
|
|
||||||
redacted: obj.redacted ?? false,
|
|
||||||
})),
|
|
||||||
civilianPresence: mission.civilianPresence ?? "None",
|
|
||||||
radioPlan: lexicalToText(mission.commsAndDocs?.radioPlan ?? null),
|
|
||||||
insertion: mission.logisticsAndSupport?.insertion ?? [],
|
|
||||||
extraction: mission.logisticsAndSupport?.extraction ?? [],
|
|
||||||
assetsAvailable: (mission.logisticsAndSupport?.assetsAvailable ?? []).map(
|
|
||||||
(asset) => ({
|
|
||||||
assetType: asset.assetType,
|
|
||||||
assetCount: asset.assetCount ?? "",
|
|
||||||
doesRespawn: asset.doesRespawn ?? false,
|
|
||||||
assetCallsign: asset.assetCallsign ?? "",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
requiredModsId:
|
|
||||||
typeof mission.gameDetails.gameSetup?.requiredMods === "object" &&
|
|
||||||
mission.gameDetails.gameSetup?.requiredMods !== null
|
|
||||||
? mission.gameDetails.gameSetup.requiredMods.id
|
|
||||||
: (mission.gameDetails.gameSetup?.requiredMods ?? null),
|
|
||||||
}}
|
|
||||||
status={mission.ownershipAndStatus.status}
|
|
||||||
maps={sideOpMaps}
|
|
||||||
campaigns={sideOpCampaigns}
|
|
||||||
modLists={sideOpModLists}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MissionAttendance
|
<MissionAttendance
|
||||||
missionId={mission.id}
|
missionId={mission.id}
|
||||||
currentUserId={currentUser ? (currentUser.id as number) : null}
|
currentUserId={currentUser ? (currentUser.id as number) : null}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,6 @@ import { getPayload } from "payload";
|
||||||
import type { AttendanceResponse } from "@/lib/attendance";
|
import type { AttendanceResponse } from "@/lib/attendance";
|
||||||
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
|
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
|
||||||
import { notifyAttendanceChanged } from "@/lib/attendance/realtime";
|
import { notifyAttendanceChanged } from "@/lib/attendance/realtime";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
|
||||||
import { textToLexical } from "@/lib/intelligence/briefingText";
|
|
||||||
import type { Mission } from "@/payload-types";
|
|
||||||
|
|
||||||
export interface ActionResult<T = undefined> {
|
export interface ActionResult<T = undefined> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -25,7 +19,7 @@ async function authenticate() {
|
||||||
const hdrs = await headers();
|
const hdrs = await headers();
|
||||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||||
|
|
||||||
if (!user || !isPayloadUser(user)) {
|
if (!user) {
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,726 +57,3 @@ export async function setMissionAttendance(input: {
|
||||||
: { success: false, error: message };
|
: { success: false, error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MISSION_TYPES = ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"] as const;
|
|
||||||
|
|
||||||
// Not exported: "use server" files may only export async functions.
|
|
||||||
const MAX_SCHEDULED_AHEAD_MISSIONS = 2;
|
|
||||||
|
|
||||||
function isFridayDate(value: string): boolean {
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return date.getDay() === 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Side ops always start 20:00 server-local (Eastern) on the validated Friday.
|
|
||||||
function fridayStartDateTime(value: string): string {
|
|
||||||
const date = new Date(value);
|
|
||||||
date.setHours(20, 0, 0, 0);
|
|
||||||
return date.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
type SideOpMissionUser = {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
displayName?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function resolveSideOpCampaign(
|
|
||||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
|
||||||
user: SideOpMissionUser,
|
|
||||||
campaignId?: number | null,
|
|
||||||
newCampaignName?: string | null,
|
|
||||||
): Promise<{ campaignId: number; created: boolean }> {
|
|
||||||
if (campaignId) {
|
|
||||||
const campaign = await payload.findByID({
|
|
||||||
collection: "campaigns",
|
|
||||||
id: campaignId,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
const ownerId = typeof campaign.owner === "object" ? campaign.owner?.id : campaign.owner;
|
|
||||||
if (ownerId !== user.id) {
|
|
||||||
throw new Error("You can only schedule missions into campaigns you own.");
|
|
||||||
}
|
|
||||||
return { campaignId: campaign.id, created: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const name =
|
|
||||||
newCampaignName?.trim() || `${user.displayName || user.username}'s Side Ops`;
|
|
||||||
|
|
||||||
const existing = await payload.find({
|
|
||||||
collection: "campaigns",
|
|
||||||
where: { and: [{ name: { equals: name } }, { owner: { equals: user.id } }] },
|
|
||||||
limit: 1,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
if (existing.docs.length > 0) {
|
|
||||||
return { campaignId: existing.docs[0].id, created: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const campaign = await payload.create({
|
|
||||||
collection: "campaigns",
|
|
||||||
overrideAccess: true,
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
summary: "Player-organized Friday side operations.",
|
|
||||||
status: "concept",
|
|
||||||
campaignMode: "custom",
|
|
||||||
owner: user.id,
|
|
||||||
startDate: new Date().toISOString().slice(0, 10),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { campaignId: campaign.id, created: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function countUpcomingPlayerMissions(
|
|
||||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
|
||||||
userId: number,
|
|
||||||
excludeMissionId?: number,
|
|
||||||
): Promise<number> {
|
|
||||||
const result = await payload.count({
|
|
||||||
collection: "missions",
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ createdBy: { equals: userId } },
|
|
||||||
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
|
||||||
{ "ownershipAndStatus.status": { not_in: ["Completed", "Cancelled"] } },
|
|
||||||
...(excludeMissionId ? [{ id: { not_equals: excludeMissionId } }] : []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return result.totalDocs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSideOpOwner(
|
|
||||||
mission: { createdBy?: number | { id: number } | null },
|
|
||||||
userId: number,
|
|
||||||
): boolean {
|
|
||||||
const ownerId = typeof mission.createdBy === "object" ? mission.createdBy?.id : mission.createdBy;
|
|
||||||
return ownerId === userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
type InfilExfilMethod = "helicopter" | "aircraft" | "paradrop" | "boat" | "car" | "onFoot";
|
|
||||||
|
|
||||||
const INFIL_EXFIL_METHODS: readonly InfilExfilMethod[] = [
|
|
||||||
"helicopter",
|
|
||||||
"aircraft",
|
|
||||||
"paradrop",
|
|
||||||
"boat",
|
|
||||||
"car",
|
|
||||||
"onFoot",
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function uploadSideOpModlist(
|
|
||||||
formData: FormData,
|
|
||||||
): Promise<ActionResult<{ modlistId: number; modlistName: string }>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
const userId = user.id as number;
|
|
||||||
|
|
||||||
const file = formData.get("file");
|
|
||||||
const name = formData.get("name");
|
|
||||||
if (!(file instanceof File) || file.size === 0) {
|
|
||||||
return { success: false, error: "Please choose a mod list file." };
|
|
||||||
}
|
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
|
||||||
return { success: false, error: "Please give the mod list a name." };
|
|
||||||
}
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
return { success: false, error: "Mod list file must be 10 MB or smaller." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
const created = await payload.create({
|
|
||||||
collection: "mod-lists",
|
|
||||||
overrideAccess: true,
|
|
||||||
data: {
|
|
||||||
modlistName: name.trim(),
|
|
||||||
author: userId,
|
|
||||||
},
|
|
||||||
file: {
|
|
||||||
data: buffer,
|
|
||||||
mimetype: file.type || "text/html",
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, data: { modlistId: created.id, modlistName: created.modlistName } };
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return message === "Unauthorized"
|
|
||||||
? { success: false, error: "You must be logged in." }
|
|
||||||
: { success: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FridaySideOpInput {
|
|
||||||
name: string;
|
|
||||||
codeName: string;
|
|
||||||
summary: string;
|
|
||||||
mapId?: number;
|
|
||||||
missionType: (typeof MISSION_TYPES)[number];
|
|
||||||
startDateTime: string;
|
|
||||||
estimatedDuration?: number;
|
|
||||||
maxPlayers?: number;
|
|
||||||
campaignId?: number | null;
|
|
||||||
newCampaignName?: string | null;
|
|
||||||
noCampaign?: boolean;
|
|
||||||
customMapName?: string | null;
|
|
||||||
requiredModsId?: number | null;
|
|
||||||
briefing?: { sectionName: string; details: string }[] | null;
|
|
||||||
missionObjectives?: {
|
|
||||||
title: string;
|
|
||||||
description?: string | null;
|
|
||||||
missionCritical?: boolean | null;
|
|
||||||
successOrFailConditions?: string | null;
|
|
||||||
redacted?: boolean | null;
|
|
||||||
}[] | null;
|
|
||||||
civilianPresence?: "None" | "Low" | "Medium" | "High" | null;
|
|
||||||
factionsInvolved?: {
|
|
||||||
faction: number;
|
|
||||||
threatLevel: "ally" | "friendly" | "neutral" | "hostile";
|
|
||||||
}[] | null;
|
|
||||||
radioPlan?: string | null;
|
|
||||||
insertion?: InfilExfilMethod[] | null;
|
|
||||||
extraction?: InfilExfilMethod[] | null;
|
|
||||||
assetsAvailable?: {
|
|
||||||
assetType: string;
|
|
||||||
assetCount?: string | null;
|
|
||||||
doesRespawn?: boolean | null;
|
|
||||||
assetCallsign?: string | null;
|
|
||||||
}[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SideOpNarrativeData = {
|
|
||||||
briefing?: { sectionName: string; details: ReturnType<typeof textToLexical> }[];
|
|
||||||
missionObjectives?: FridaySideOpInput["missionObjectives"];
|
|
||||||
civilianPresence?: FridaySideOpInput["civilianPresence"];
|
|
||||||
factionsInvolved?: FridaySideOpInput["factionsInvolved"];
|
|
||||||
commsAndDocs?: { radioPlan?: ReturnType<typeof textToLexical> };
|
|
||||||
logisticsAndSupport?: {
|
|
||||||
insertion?: InfilExfilMethod[] | null;
|
|
||||||
extraction?: InfilExfilMethod[] | null;
|
|
||||||
assetsAvailable?: FridaySideOpInput["assetsAvailable"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function narrativeData(input: {
|
|
||||||
briefing?: FridaySideOpInput["briefing"];
|
|
||||||
missionObjectives?: FridaySideOpInput["missionObjectives"];
|
|
||||||
civilianPresence?: FridaySideOpInput["civilianPresence"];
|
|
||||||
factionsInvolved?: FridaySideOpInput["factionsInvolved"];
|
|
||||||
radioPlan?: FridaySideOpInput["radioPlan"];
|
|
||||||
insertion?: FridaySideOpInput["insertion"];
|
|
||||||
extraction?: FridaySideOpInput["extraction"];
|
|
||||||
assetsAvailable?: FridaySideOpInput["assetsAvailable"];
|
|
||||||
}): SideOpNarrativeData {
|
|
||||||
const commsAndDocs: SideOpNarrativeData["commsAndDocs"] = {};
|
|
||||||
if (input.radioPlan !== undefined) {
|
|
||||||
commsAndDocs.radioPlan = textToLexical(input.radioPlan ?? "");
|
|
||||||
}
|
|
||||||
const logisticsAndSupport: SideOpNarrativeData["logisticsAndSupport"] = {};
|
|
||||||
if (input.insertion !== undefined) {
|
|
||||||
logisticsAndSupport.insertion = input.insertion;
|
|
||||||
}
|
|
||||||
if (input.extraction !== undefined) {
|
|
||||||
logisticsAndSupport.extraction = input.extraction;
|
|
||||||
}
|
|
||||||
if (input.assetsAvailable !== undefined) {
|
|
||||||
logisticsAndSupport.assetsAvailable = input.assetsAvailable;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...(input.briefing !== undefined
|
|
||||||
? {
|
|
||||||
briefing: (input.briefing ?? []).map((entry) => ({
|
|
||||||
sectionName: entry.sectionName,
|
|
||||||
details: textToLexical(entry.details),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(input.missionObjectives !== undefined ? { missionObjectives: input.missionObjectives } : {}),
|
|
||||||
...(input.civilianPresence !== undefined ? { civilianPresence: input.civilianPresence } : {}),
|
|
||||||
...(input.factionsInvolved !== undefined ? { factionsInvolved: input.factionsInvolved } : {}),
|
|
||||||
...(Object.keys(commsAndDocs).length > 0 ? { commsAndDocs } : {}),
|
|
||||||
...(Object.keys(logisticsAndSupport).length > 0 ? { logisticsAndSupport } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findActiveMissionOnFriday(
|
|
||||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
|
||||||
startDateTime: string,
|
|
||||||
excludeMissionId?: number,
|
|
||||||
): Promise<{ id: number; name: string; codeName: string } | null> {
|
|
||||||
const start = new Date(fridayStartDateTime(startDateTime));
|
|
||||||
const dayStart = new Date(start);
|
|
||||||
dayStart.setHours(0, 0, 0, 0);
|
|
||||||
const dayEnd = new Date(dayStart);
|
|
||||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
|
||||||
|
|
||||||
const res = await payload.find({
|
|
||||||
collection: "missions",
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ "classification.startDateTime": { greater_than_equal: dayStart.toISOString() } },
|
|
||||||
{ "classification.startDateTime": { less_than: dayEnd.toISOString() } },
|
|
||||||
{ "ownershipAndStatus.status": { not_in: ["Completed", "Cancelled"] } },
|
|
||||||
...(excludeMissionId ? [{ id: { not_equals: excludeMissionId } }] : []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
depth: 0,
|
|
||||||
limit: 1,
|
|
||||||
overrideAccess: true,
|
|
||||||
select: { name: true, codeName: true },
|
|
||||||
});
|
|
||||||
return res.docs[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveSideOpMap(
|
|
||||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
|
||||||
input: { mapId?: number | null; customMapName?: string | null },
|
|
||||||
): Promise<number | null> {
|
|
||||||
const customName = input.customMapName?.trim();
|
|
||||||
if (customName) {
|
|
||||||
const exact = await payload.find({
|
|
||||||
collection: "maps",
|
|
||||||
where: { name: { equals: customName } },
|
|
||||||
depth: 0,
|
|
||||||
limit: 1,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
if (exact.docs.length > 0) return exact.docs[0].id;
|
|
||||||
const all = await payload.find({
|
|
||||||
collection: "maps",
|
|
||||||
depth: 0,
|
|
||||||
limit: 500,
|
|
||||||
overrideAccess: true,
|
|
||||||
sort: "name",
|
|
||||||
});
|
|
||||||
const existing = all.docs.find(
|
|
||||||
(m) => m.name.toLocaleLowerCase() === customName.toLocaleLowerCase(),
|
|
||||||
);
|
|
||||||
if (existing) return existing.id;
|
|
||||||
const created = await payload.create({
|
|
||||||
collection: "maps",
|
|
||||||
data: { name: customName },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
return created.id;
|
|
||||||
}
|
|
||||||
return input.mapId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createFridaySideOp(
|
|
||||||
input: FridaySideOpInput,
|
|
||||||
): Promise<ActionResult<{ missionId: number }>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
const userId = user.id as number;
|
|
||||||
|
|
||||||
if (!input.name?.trim() || !input.summary?.trim() || !input.codeName?.trim()) {
|
|
||||||
return { success: false, error: "Name, code name, and summary are required." };
|
|
||||||
}
|
|
||||||
if (!input.mapId && !input.customMapName?.trim()) {
|
|
||||||
return { success: false, error: "Please choose a map." };
|
|
||||||
}
|
|
||||||
if (!MISSION_TYPES.includes(input.missionType)) {
|
|
||||||
return { success: false, error: "Please choose a valid mission type." };
|
|
||||||
}
|
|
||||||
if (!isFridayDate(input.startDateTime)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Side operations can only be scheduled on a Friday.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const startDate = new Date(input.startDateTime);
|
|
||||||
if (Number.isNaN(startDate.getTime())) {
|
|
||||||
return { success: false, error: "Invalid start date/time." };
|
|
||||||
}
|
|
||||||
if (startDate.getTime() <= Date.now()) {
|
|
||||||
return { success: false, error: "Side operations must be scheduled in the future." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduledAhead = await countUpcomingPlayerMissions(payload, userId);
|
|
||||||
if (scheduledAhead >= MAX_SCHEDULED_AHEAD_MISSIONS) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `You already have ${MAX_SCHEDULED_AHEAD_MISSIONS} upcoming missions scheduled. Cancel or complete one before scheduling another.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fridayConflict = await findActiveMissionOnFriday(payload, input.startDateTime);
|
|
||||||
if (fridayConflict) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `That Friday is already taken by "${fridayConflict.name}" (${fridayConflict.codeName}) — only one operation per Friday, first come first served.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const campaignId = input.noCampaign
|
|
||||||
? null
|
|
||||||
: (
|
|
||||||
await resolveSideOpCampaign(
|
|
||||||
payload,
|
|
||||||
{ id: userId, username: user.username, displayName: user.displayName },
|
|
||||||
input.campaignId ?? null,
|
|
||||||
input.newCampaignName ?? null,
|
|
||||||
)
|
|
||||||
).campaignId;
|
|
||||||
|
|
||||||
const resolvedMapId = await resolveSideOpMap(payload, {
|
|
||||||
mapId: input.mapId,
|
|
||||||
customMapName: input.customMapName,
|
|
||||||
});
|
|
||||||
if (resolvedMapId == null) {
|
|
||||||
return { success: false, error: "Please choose a map." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const mission = await payload.create({
|
|
||||||
collection: "missions",
|
|
||||||
overrideAccess: true,
|
|
||||||
data: {
|
|
||||||
name: input.name.trim(),
|
|
||||||
codeName: input.codeName.trim(),
|
|
||||||
summary: input.summary.trim(),
|
|
||||||
operationType: "side",
|
|
||||||
createdBy: userId,
|
|
||||||
ownershipAndStatus: {
|
|
||||||
status: "Scheduled",
|
|
||||||
visibility: "unit",
|
|
||||||
authors: [userId],
|
|
||||||
},
|
|
||||||
classification: {
|
|
||||||
map: resolvedMapId,
|
|
||||||
missionType: input.missionType,
|
|
||||||
campaign: campaignId,
|
|
||||||
startDateTime: fridayStartDateTime(input.startDateTime),
|
|
||||||
estimatedDuration: input.estimatedDuration ?? 240,
|
|
||||||
},
|
|
||||||
missionRoles: {
|
|
||||||
maxPlayers: input.maxPlayers ?? 32,
|
|
||||||
},
|
|
||||||
gameDetails: {
|
|
||||||
serverDetails: {
|
|
||||||
serverIp: "arma.onyxsimple.com",
|
|
||||||
serverPort: 2302,
|
|
||||||
},
|
|
||||||
...(input.requiredModsId
|
|
||||||
? { gameSetup: { requiredMods: input.requiredModsId } }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
...narrativeData(input),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const startIso = fridayStartDateTime(input.startDateTime);
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.MissionSchedule,
|
|
||||||
message: `${user.displayName || user.username} scheduled side operation "${mission.name}" (${mission.codeName}) for ${new Date(startIso).toLocaleString()}.`,
|
|
||||||
actor: userId,
|
|
||||||
targetCollection: "missions",
|
|
||||||
targetId: mission.id,
|
|
||||||
data: { startDateTime: startIso, campaignId },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, data: { missionId: mission.id } };
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return message === "Unauthorized"
|
|
||||||
? { success: false, error: "You must be logged in." }
|
|
||||||
: { success: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateFridaySideOpInput {
|
|
||||||
missionId: number;
|
|
||||||
name?: string;
|
|
||||||
codeName?: string;
|
|
||||||
summary?: string;
|
|
||||||
mapId?: number;
|
|
||||||
missionType?: (typeof MISSION_TYPES)[number];
|
|
||||||
startDateTime?: string;
|
|
||||||
estimatedDuration?: number;
|
|
||||||
maxPlayers?: number;
|
|
||||||
campaignId?: number | null;
|
|
||||||
newCampaignName?: string | null;
|
|
||||||
noCampaign?: boolean;
|
|
||||||
customMapName?: string | null;
|
|
||||||
requiredModsId?: number | null;
|
|
||||||
briefing?: { sectionName: string; details: string }[] | null;
|
|
||||||
missionObjectives?: FridaySideOpInput["missionObjectives"];
|
|
||||||
civilianPresence?: FridaySideOpInput["civilianPresence"];
|
|
||||||
factionsInvolved?: FridaySideOpInput["factionsInvolved"];
|
|
||||||
radioPlan?: string | null;
|
|
||||||
insertion?: InfilExfilMethod[] | null;
|
|
||||||
extraction?: InfilExfilMethod[] | null;
|
|
||||||
assetsAvailable?: FridaySideOpInput["assetsAvailable"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateFridaySideOp(
|
|
||||||
input: UpdateFridaySideOpInput,
|
|
||||||
): Promise<ActionResult<{ missionId: number }>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
const userId = user.id as number;
|
|
||||||
|
|
||||||
const mission = await payload.findByID({
|
|
||||||
collection: "missions",
|
|
||||||
id: input.missionId,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const canManage = await hasPermission(payload, user, "missions:update");
|
|
||||||
if (!canManage && !isSideOpOwner(mission, userId)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Only the player who scheduled this side operation can edit it.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (["Completed", "Cancelled"].includes(mission.ownershipAndStatus.status)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `This mission is ${mission.ownershipAndStatus.status.toLowerCase()} and can no longer be edited.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.startDateTime !== undefined) {
|
|
||||||
if (!isFridayDate(input.startDateTime)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Side operations can only be scheduled on a Friday.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const startDate = new Date(input.startDateTime);
|
|
||||||
if (Number.isNaN(startDate.getTime())) {
|
|
||||||
return { success: false, error: "Invalid start date/time." };
|
|
||||||
}
|
|
||||||
if (startDate.getTime() <= Date.now()) {
|
|
||||||
return { success: false, error: "Side operations must be scheduled in the future." };
|
|
||||||
}
|
|
||||||
const fridayConflict = await findActiveMissionOnFriday(
|
|
||||||
payload,
|
|
||||||
input.startDateTime,
|
|
||||||
input.missionId,
|
|
||||||
);
|
|
||||||
if (fridayConflict) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `That Friday is already taken by "${fridayConflict.name}" (${fridayConflict.codeName}) — only one operation per Friday, first come first served.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolvedCampaignId: number | null = null;
|
|
||||||
let campaignChangeRequested = false;
|
|
||||||
if (input.noCampaign) {
|
|
||||||
campaignChangeRequested = true;
|
|
||||||
} else if (input.campaignId != null || (input.newCampaignName && input.newCampaignName.trim())) {
|
|
||||||
const resolved = await resolveSideOpCampaign(
|
|
||||||
payload,
|
|
||||||
user,
|
|
||||||
input.campaignId ?? null,
|
|
||||||
input.newCampaignName ?? null,
|
|
||||||
);
|
|
||||||
resolvedCampaignId = resolved.campaignId;
|
|
||||||
campaignChangeRequested = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SideOpClassificationUpdate = {
|
|
||||||
map?: number;
|
|
||||||
missionType?: Mission["classification"]["missionType"];
|
|
||||||
campaign?: number | null;
|
|
||||||
startDateTime?: string;
|
|
||||||
estimatedDuration?: number;
|
|
||||||
};
|
|
||||||
const classification: SideOpClassificationUpdate = {};
|
|
||||||
const data: {
|
|
||||||
name?: string;
|
|
||||||
codeName?: string;
|
|
||||||
summary?: string;
|
|
||||||
classification?: SideOpClassificationUpdate;
|
|
||||||
missionRoles?: { maxPlayers?: number };
|
|
||||||
gameDetails?: { gameSetup?: { requiredMods?: number | null } };
|
|
||||||
} = {};
|
|
||||||
if (input.name !== undefined) data.name = input.name.trim();
|
|
||||||
if (input.codeName !== undefined) data.codeName = input.codeName.trim();
|
|
||||||
if (input.summary !== undefined) data.summary = input.summary.trim();
|
|
||||||
if (input.estimatedDuration !== undefined) {
|
|
||||||
classification.estimatedDuration = input.estimatedDuration;
|
|
||||||
}
|
|
||||||
if (input.mapId !== undefined || input.customMapName !== undefined) {
|
|
||||||
const resolvedMapId = await resolveSideOpMap(payload, {
|
|
||||||
mapId: input.mapId,
|
|
||||||
customMapName: input.customMapName,
|
|
||||||
});
|
|
||||||
if (resolvedMapId != null) {
|
|
||||||
classification.map = resolvedMapId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (input.missionType !== undefined) {
|
|
||||||
classification.missionType = input.missionType;
|
|
||||||
}
|
|
||||||
if (input.startDateTime !== undefined) {
|
|
||||||
classification.startDateTime = fridayStartDateTime(input.startDateTime);
|
|
||||||
}
|
|
||||||
if (campaignChangeRequested) {
|
|
||||||
classification.campaign = resolvedCampaignId;
|
|
||||||
}
|
|
||||||
if (Object.keys(classification).length > 0) {
|
|
||||||
data.classification = classification;
|
|
||||||
}
|
|
||||||
if (input.maxPlayers !== undefined) {
|
|
||||||
data.missionRoles = { maxPlayers: input.maxPlayers };
|
|
||||||
}
|
|
||||||
if (input.requiredModsId !== undefined) {
|
|
||||||
data.gameDetails = { gameSetup: { requiredMods: input.requiredModsId } };
|
|
||||||
}
|
|
||||||
Object.assign(data, narrativeData(input));
|
|
||||||
|
|
||||||
const updated = await payload.update({
|
|
||||||
collection: "missions",
|
|
||||||
id: input.missionId,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.MissionUpdate,
|
|
||||||
message: `${user.displayName || user.username} updated side operation "${updated.name}" (${updated.codeName}).`,
|
|
||||||
actor: userId,
|
|
||||||
targetCollection: "missions",
|
|
||||||
targetId: updated.id,
|
|
||||||
data: { fields: Object.keys(data) },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, data: { missionId: updated.id } };
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return message === "Unauthorized"
|
|
||||||
? { success: false, error: "You must be logged in." }
|
|
||||||
: { success: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelFridaySideOp(
|
|
||||||
missionId: number,
|
|
||||||
): Promise<ActionResult<{ missionId: number }>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
const userId = user.id as number;
|
|
||||||
|
|
||||||
const mission = await payload.findByID({
|
|
||||||
collection: "missions",
|
|
||||||
id: missionId,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const canManage =
|
|
||||||
(await hasPermission(payload, user, "missions:update")) ||
|
|
||||||
(await hasPermission(payload, user, "missions:delete"));
|
|
||||||
if (!canManage && !isSideOpOwner(mission, userId)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Only the player who scheduled this side operation can cancel it.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (["Completed", "Cancelled"].includes(mission.ownershipAndStatus.status)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `This mission is already ${mission.ownershipAndStatus.status.toLowerCase()}.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await payload.update({
|
|
||||||
collection: "missions",
|
|
||||||
id: missionId,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
data: {
|
|
||||||
ownershipAndStatus: {
|
|
||||||
status: "Cancelled",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.MissionCancel,
|
|
||||||
message: `${user.displayName || user.username} cancelled side operation "${updated.name}" (${updated.codeName}).`,
|
|
||||||
actor: userId,
|
|
||||||
targetCollection: "missions",
|
|
||||||
targetId: updated.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, data: { missionId: updated.id } };
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return message === "Unauthorized"
|
|
||||||
? { success: false, error: "You must be logged in." }
|
|
||||||
: { success: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function moveFridaySideOpToCampaign(
|
|
||||||
missionId: number,
|
|
||||||
campaignId: number,
|
|
||||||
): Promise<ActionResult<{ missionId: number }>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
const userId = user.id as number;
|
|
||||||
|
|
||||||
const mission = await payload.findByID({
|
|
||||||
collection: "missions",
|
|
||||||
id: missionId,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const canManage = await hasPermission(payload, user, "missions:update");
|
|
||||||
if (!canManage && !isSideOpOwner(mission, userId)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Only the player who scheduled this side operation can move it.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const campaign = await payload.findByID({
|
|
||||||
collection: "campaigns",
|
|
||||||
id: campaignId,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
const ownerId = typeof campaign.owner === "object" ? campaign.owner?.id : campaign.owner;
|
|
||||||
if (ownerId !== userId) {
|
|
||||||
return { success: false, error: "You can only move missions into campaigns you own." };
|
|
||||||
}
|
|
||||||
|
|
||||||
await payload.update({
|
|
||||||
collection: "missions",
|
|
||||||
id: missionId,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
data: {
|
|
||||||
classification: {
|
|
||||||
campaign: campaignId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.MissionMoveCampaign,
|
|
||||||
message: `${user.displayName || user.username} moved side operation "${mission.name}" (${mission.codeName}) to campaign "${campaign.name}".`,
|
|
||||||
actor: userId,
|
|
||||||
targetCollection: "missions",
|
|
||||||
targetId: missionId,
|
|
||||||
data: { campaignId },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, data: { missionId } };
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return message === "Unauthorized"
|
|
||||||
? { success: false, error: "You must be logged in." }
|
|
||||||
: { success: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
import config from "@payload-config";
|
|
||||||
import { getPayload } from "payload";
|
|
||||||
import { headers as nextHeaders } from "next/headers";
|
|
||||||
import {
|
|
||||||
FridayCalendar,
|
|
||||||
type FridayCalendarEntry,
|
|
||||||
type FridayCalendarMission,
|
|
||||||
} from "@/components/frontend/intelligence/FridayCalendar";
|
|
||||||
import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Friday Operations",
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_SCHEDULED_AHEAD_MISSIONS = 2;
|
|
||||||
|
|
||||||
const FRIDAYS_SHOWN = 8;
|
|
||||||
|
|
||||||
function nextFridays(count: number): Date[] {
|
|
||||||
const fridays: Date[] = [];
|
|
||||||
const cursor = new Date();
|
|
||||||
cursor.setHours(0, 0, 0, 0);
|
|
||||||
while (fridays.length < count) {
|
|
||||||
if (cursor.getDay() === 5) {
|
|
||||||
fridays.push(new Date(cursor));
|
|
||||||
}
|
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
|
||||||
}
|
|
||||||
return fridays;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function FridayCalendarPage() {
|
|
||||||
const payload = await getPayload({ config });
|
|
||||||
|
|
||||||
const { user } = await payload.auth({
|
|
||||||
headers: await nextHeaders(),
|
|
||||||
canSetHeaders: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fridays = nextFridays(FRIDAYS_SHOWN);
|
|
||||||
const rangeStart = fridays[0];
|
|
||||||
const rangeEnd = new Date(fridays[fridays.length - 1]);
|
|
||||||
rangeEnd.setDate(rangeEnd.getDate() + 1);
|
|
||||||
|
|
||||||
const missionsRes = await payload.find({
|
|
||||||
collection: "missions",
|
|
||||||
where: {
|
|
||||||
"classification.startDateTime": {
|
|
||||||
greater_than_equal: rangeStart.toISOString(),
|
|
||||||
less_than: rangeEnd.toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort: "classification.startDateTime",
|
|
||||||
depth: 1,
|
|
||||||
limit: 200,
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
codeName: true,
|
|
||||||
operationType: true,
|
|
||||||
ownershipAndStatus: { status: true },
|
|
||||||
classification: { startDateTime: true, map: true },
|
|
||||||
createdBy: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const entries: FridayCalendarEntry[] = fridays.map((friday) => {
|
|
||||||
const dayStart = new Date(friday);
|
|
||||||
const dayEnd = new Date(friday);
|
|
||||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
|
||||||
const missions: FridayCalendarMission[] = missionsRes.docs
|
|
||||||
.filter((m) => {
|
|
||||||
const start = new Date(m.classification.startDateTime);
|
|
||||||
return start >= dayStart && start < dayEnd;
|
|
||||||
})
|
|
||||||
.map((m) => {
|
|
||||||
const map = m.classification.map;
|
|
||||||
const createdBy = m.createdBy;
|
|
||||||
return {
|
|
||||||
id: m.id,
|
|
||||||
name: m.name,
|
|
||||||
codeName: m.codeName,
|
|
||||||
status: m.ownershipAndStatus.status,
|
|
||||||
startDateTime: m.classification.startDateTime,
|
|
||||||
operationType: m.operationType,
|
|
||||||
mapName: typeof map === "object" && map !== null ? map.name : null,
|
|
||||||
isOwn:
|
|
||||||
user != null &&
|
|
||||||
(typeof createdBy === "object" ? createdBy?.id : createdBy) === user.id,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { fridayIso: friday.toISOString(), missions };
|
|
||||||
});
|
|
||||||
|
|
||||||
let remainingSlots = 0;
|
|
||||||
let maps: SideOpDialogOption[] = [];
|
|
||||||
let campaigns: SideOpDialogOption[] = [];
|
|
||||||
let modLists: SideOpDialogOption[] = [];
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
const userId = user.id as number;
|
|
||||||
const [scheduledCount, mapsRes, campaignsRes, modListsRes] = await Promise.all([
|
|
||||||
payload.count({
|
|
||||||
collection: "missions",
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ createdBy: { equals: userId } },
|
|
||||||
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
|
||||||
{ "ownershipAndStatus.status": { not_in: ["Completed", "Cancelled"] } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
payload.find({
|
|
||||||
collection: "maps",
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
limit: 200,
|
|
||||||
sort: "name",
|
|
||||||
select: { name: true },
|
|
||||||
}),
|
|
||||||
payload.find({
|
|
||||||
collection: "campaigns",
|
|
||||||
where: { owner: { equals: userId } },
|
|
||||||
depth: 0,
|
|
||||||
limit: 100,
|
|
||||||
sort: "name",
|
|
||||||
select: { name: true },
|
|
||||||
}),
|
|
||||||
payload.find({
|
|
||||||
collection: "mod-lists",
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
limit: 200,
|
|
||||||
sort: "modlistName",
|
|
||||||
select: { modlistName: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
remainingSlots = Math.max(0, MAX_SCHEDULED_AHEAD_MISSIONS - scheduledCount.totalDocs);
|
|
||||||
maps = mapsRes.docs.map((m) => ({ id: m.id, name: m.name }));
|
|
||||||
campaigns = campaignsRes.docs.map((c) => ({ id: c.id, name: c.name }));
|
|
||||||
modLists = modListsRes.docs.map((l) => ({ id: l.id, name: l.modlistName }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-5">
|
|
||||||
<div className="mx-auto w-full max-w-5xl">
|
|
||||||
<FridayCalendar
|
|
||||||
entries={entries}
|
|
||||||
remainingSlots={remainingSlots}
|
|
||||||
maxSlots={MAX_SCHEDULED_AHEAD_MISSIONS}
|
|
||||||
maps={maps}
|
|
||||||
campaigns={campaigns}
|
|
||||||
modLists={modLists}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -536,11 +536,6 @@
|
||||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown .note > :first-child,
|
|
||||||
.wiki-markdown .warning > :first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-markdown .note .callout-icon {
|
.wiki-markdown .note .callout-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.75rem;
|
left: 0.75rem;
|
||||||
|
|
|
||||||
|
|
@ -206,15 +206,6 @@ export const Missions: CollectionConfig = {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "createdBy",
|
|
||||||
type: "relationship",
|
|
||||||
relationTo: "users",
|
|
||||||
admin: {
|
|
||||||
description:
|
|
||||||
"Player who scheduled this mission via the Friday Ops calendar (ownership + scheduling-cap tracking). Set automatically; leave empty for staff-created missions.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "coverImage",
|
name: "coverImage",
|
||||||
type: "upload",
|
type: "upload",
|
||||||
|
|
@ -253,7 +244,7 @@ export const Missions: CollectionConfig = {
|
||||||
name: "campaign",
|
name: "campaign",
|
||||||
type: "relationship",
|
type: "relationship",
|
||||||
relationTo: "campaigns",
|
relationTo: "campaigns",
|
||||||
required: false,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "customScripts",
|
name: "customScripts",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import * as React from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Bolt,
|
Bolt,
|
||||||
CalendarDays,
|
|
||||||
Shield,
|
Shield,
|
||||||
Users,
|
Users,
|
||||||
Maximize,
|
Maximize,
|
||||||
|
|
@ -51,11 +50,6 @@ const data = {
|
||||||
url: "/transfers",
|
url: "/transfers",
|
||||||
icon: ArrowLeftRight,
|
icon: ArrowLeftRight,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Friday Ops",
|
|
||||||
url: "/intelligence/missions/calendar",
|
|
||||||
icon: CalendarDays,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
...sidebarData,
|
...sidebarData,
|
||||||
};
|
};
|
||||||
|
|
@ -169,10 +163,10 @@ export function AppSidebar({
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{isMiniMode ? (
|
{isMiniMode ? (
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="min-w-0">
|
<TooltipTrigger>
|
||||||
<div className="truncate text-xs text-muted-foreground uppercase">
|
<div className="text-xs text-muted-foreground uppercase">
|
||||||
{xpLevel.levelName}
|
{xpLevel.levelName}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -180,11 +174,9 @@ export function AppSidebar({
|
||||||
<p>{xpLevel.levelDesc}</p>
|
<p>{xpLevel.levelDesc}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<SparkleIcon className="w-4 h-4 shrink-0" />
|
<SparkleIcon className="w-4 h-4" />
|
||||||
<span className="text-xs font-bold shrink-0">
|
<span className="text-xs font-bold">{xpLevel.xp.toLocaleString()}</span>
|
||||||
{xpLevel.xp.toLocaleString()}
|
<Progress value={xpLevel.progress} className="w-20 h-1" />
|
||||||
</span>
|
|
||||||
<Progress value={xpLevel.progress} className="w-20 h-1 shrink-0" />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<XPDisplay xpLevel={xpLevel} />
|
<XPDisplay xpLevel={xpLevel} />
|
||||||
|
|
|
||||||
|
|
@ -1,513 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState, useTransition } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
createFridaySideOp,
|
|
||||||
updateFridaySideOp,
|
|
||||||
uploadSideOpModlist,
|
|
||||||
type FridaySideOpInput,
|
|
||||||
} from "@/app/(frontend)/intelligence/missions/actions";
|
|
||||||
import {
|
|
||||||
SideOpNarrativeFields,
|
|
||||||
type SideOpNarrative,
|
|
||||||
} from "@/components/frontend/intelligence/SideOpNarrativeFields";
|
|
||||||
|
|
||||||
const MISSION_TYPES = ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"] as const;
|
|
||||||
const DEFAULT_BRIEFING_SECTIONS = ["Situation", "Mission", "Execution", "Signals"];
|
|
||||||
|
|
||||||
function defaultNarrative(): SideOpNarrative {
|
|
||||||
return {
|
|
||||||
briefing: DEFAULT_BRIEFING_SECTIONS.map((sectionName) => ({ sectionName, details: "" })),
|
|
||||||
missionObjectives: [],
|
|
||||||
civilianPresence: "None",
|
|
||||||
radioPlan: "",
|
|
||||||
insertion: [],
|
|
||||||
extraction: [],
|
|
||||||
assetsAvailable: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SideOpDialogOption {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SideOpDialogInitial {
|
|
||||||
missionId: number;
|
|
||||||
name: string;
|
|
||||||
codeName: string;
|
|
||||||
summary: string;
|
|
||||||
mapId: number | null;
|
|
||||||
missionType: string;
|
|
||||||
startDateTime: string;
|
|
||||||
estimatedDuration: number;
|
|
||||||
maxPlayers: number;
|
|
||||||
campaignId: number | null;
|
|
||||||
requiredModsId?: number | null;
|
|
||||||
briefing?: { sectionName: string; details: string }[];
|
|
||||||
missionObjectives?: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
missionCritical: boolean;
|
|
||||||
successOrFailConditions: string;
|
|
||||||
redacted: boolean;
|
|
||||||
}[];
|
|
||||||
civilianPresence?: string;
|
|
||||||
radioPlan?: string;
|
|
||||||
insertion?: string[];
|
|
||||||
extraction?: string[];
|
|
||||||
assetsAvailable?: {
|
|
||||||
assetType: string;
|
|
||||||
assetCount: string;
|
|
||||||
doesRespawn: boolean;
|
|
||||||
assetCallsign: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateSideOpDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
maps: SideOpDialogOption[];
|
|
||||||
campaigns: SideOpDialogOption[];
|
|
||||||
modLists: SideOpDialogOption[];
|
|
||||||
defaultStartDateTime?: string;
|
|
||||||
initial?: SideOpDialogInitial;
|
|
||||||
onSaved?: () => void;
|
|
||||||
onModlistUploaded?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateSideOpDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
maps,
|
|
||||||
campaigns,
|
|
||||||
modLists,
|
|
||||||
defaultStartDateTime,
|
|
||||||
initial,
|
|
||||||
onSaved,
|
|
||||||
onModlistUploaded,
|
|
||||||
}: CreateSideOpDialogProps) {
|
|
||||||
const isEdit = initial !== undefined;
|
|
||||||
const [name, setName] = useState(initial?.name ?? "");
|
|
||||||
const [codeName, setCodeName] = useState(initial?.codeName ?? "");
|
|
||||||
const [summary, setSummary] = useState(initial?.summary ?? "");
|
|
||||||
const [mapId, setMapId] = useState<string>(initial?.mapId ? String(initial.mapId) : "");
|
|
||||||
const [mapChoice, setMapChoice] = useState<string>(
|
|
||||||
initial?.mapId ? String(initial.mapId) : "existing",
|
|
||||||
);
|
|
||||||
const [customMapName, setCustomMapName] = useState("");
|
|
||||||
const [missionType, setMissionType] = useState<string>(initial?.missionType ?? "Zeus");
|
|
||||||
const [requiredModsId, setRequiredModsId] = useState<string>(
|
|
||||||
initial?.requiredModsId ? String(initial.requiredModsId) : "none",
|
|
||||||
);
|
|
||||||
const [modlistName, setModlistName] = useState("");
|
|
||||||
const [modlistUploading, setModlistUploading] = useState(false);
|
|
||||||
const [estimatedDuration, setEstimatedDuration] = useState(
|
|
||||||
String(initial?.estimatedDuration ?? 240),
|
|
||||||
);
|
|
||||||
const [maxPlayers, setMaxPlayers] = useState(String(initial?.maxPlayers ?? 32));
|
|
||||||
const [campaignChoice, setCampaignChoice] = useState<string>(
|
|
||||||
initial?.campaignId ? String(initial.campaignId) : "none",
|
|
||||||
);
|
|
||||||
const [newCampaignName, setNewCampaignName] = useState("");
|
|
||||||
const [narrative, setNarrative] = useState<SideOpNarrative>(defaultNarrative);
|
|
||||||
const [narrativeOpen, setNarrativeOpen] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setError(null);
|
|
||||||
setNarrativeOpen(initial !== undefined);
|
|
||||||
if (initial) {
|
|
||||||
setName(initial.name);
|
|
||||||
setCodeName(initial.codeName);
|
|
||||||
setSummary(initial.summary);
|
|
||||||
setMapId(initial.mapId ? String(initial.mapId) : "");
|
|
||||||
setMapChoice(initial.mapId ? String(initial.mapId) : "custom");
|
|
||||||
setCustomMapName("");
|
|
||||||
setMissionType(initial.missionType);
|
|
||||||
setRequiredModsId(initial.requiredModsId ? String(initial.requiredModsId) : "none");
|
|
||||||
setModlistName("");
|
|
||||||
setEstimatedDuration(String(initial.estimatedDuration));
|
|
||||||
setMaxPlayers(String(initial.maxPlayers));
|
|
||||||
setCampaignChoice(initial.campaignId ? String(initial.campaignId) : "none");
|
|
||||||
setNarrative({
|
|
||||||
briefing: initial.briefing ?? [],
|
|
||||||
missionObjectives: initial.missionObjectives ?? [],
|
|
||||||
civilianPresence: initial.civilianPresence ?? "None",
|
|
||||||
radioPlan: initial.radioPlan ?? "",
|
|
||||||
insertion: initial.insertion ?? [],
|
|
||||||
extraction: initial.extraction ?? [],
|
|
||||||
assetsAvailable: initial.assetsAvailable ?? [],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setNarrative(defaultNarrative());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [open, initial]);
|
|
||||||
|
|
||||||
function narrativeInput() {
|
|
||||||
return {
|
|
||||||
briefing: narrative.briefing
|
|
||||||
.filter((e) => e.sectionName.trim() !== "" || e.details.trim() !== "")
|
|
||||||
.map((e) => ({ sectionName: e.sectionName.trim() || "Untitled", details: e.details })),
|
|
||||||
missionObjectives: narrative.missionObjectives
|
|
||||||
.filter((o) => o.title.trim() !== "")
|
|
||||||
.map((o) => ({
|
|
||||||
title: o.title.trim(),
|
|
||||||
description: o.description || null,
|
|
||||||
missionCritical: o.missionCritical,
|
|
||||||
successOrFailConditions: o.successOrFailConditions || null,
|
|
||||||
redacted: o.redacted,
|
|
||||||
})),
|
|
||||||
civilianPresence: narrative.civilianPresence as "None" | "Low" | "Medium" | "High",
|
|
||||||
radioPlan: narrative.radioPlan,
|
|
||||||
insertion: narrative.insertion as FridaySideOpInput["insertion"],
|
|
||||||
extraction: narrative.extraction as FridaySideOpInput["extraction"],
|
|
||||||
assetsAvailable: narrative.assetsAvailable.map((a) => ({
|
|
||||||
assetType: a.assetType.trim(),
|
|
||||||
assetCount: a.assetCount || null,
|
|
||||||
doesRespawn: a.doesRespawn,
|
|
||||||
assetCallsign: a.assetCallsign || null,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapInput() {
|
|
||||||
return mapChoice === "custom" && customMapName.trim() !== ""
|
|
||||||
? { customMapName }
|
|
||||||
: mapId
|
|
||||||
? { mapId: Number(mapId), customMapName: null }
|
|
||||||
: { customMapName: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function modsInput() {
|
|
||||||
return requiredModsId === "none" ? { requiredModsId: null } : { requiredModsId: Number(requiredModsId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleModlistUpload(file: File) {
|
|
||||||
setModlistUploading(true);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
formData.append("name", modlistName.trim() || file.name.replace(/\.html?$/i, ""));
|
|
||||||
const result = await uploadSideOpModlist(formData);
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
toast.error(result.error ?? "Failed to upload the mod list.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRequiredModsId(String(result.data.modlistId));
|
|
||||||
toast.success(`Mod list "${result.data.modlistName}" uploaded.`);
|
|
||||||
onModlistUploaded?.();
|
|
||||||
} finally {
|
|
||||||
setModlistUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit() {
|
|
||||||
setError(null);
|
|
||||||
if (!name.trim() || !codeName.trim() || !summary.trim()) {
|
|
||||||
setError("Name, code name, and summary are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mapChoice === "custom" && !customMapName.trim()) {
|
|
||||||
setError("Please enter a name for the custom map.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mapChoice !== "custom" && !mapId) {
|
|
||||||
setError("Please choose a map.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isEdit && !defaultStartDateTime) {
|
|
||||||
setError("Missing start date — schedule from a Friday on the calendar.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const startDateIso = defaultStartDateTime ?? "";
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
if (isEdit) {
|
|
||||||
const initialChoice = initial.campaignId ? String(initial.campaignId) : "none";
|
|
||||||
const campaignChanged = campaignChoice !== initialChoice;
|
|
||||||
const result = await updateFridaySideOp({
|
|
||||||
missionId: initial.missionId,
|
|
||||||
name,
|
|
||||||
codeName,
|
|
||||||
summary,
|
|
||||||
...mapInput(),
|
|
||||||
missionType: missionType as FridaySideOpInput["missionType"],
|
|
||||||
estimatedDuration: Number(estimatedDuration) || 240,
|
|
||||||
maxPlayers: Number(maxPlayers) || 32,
|
|
||||||
...modsInput(),
|
|
||||||
...(campaignChanged
|
|
||||||
? campaignChoice === "none"
|
|
||||||
? { noCampaign: true }
|
|
||||||
: campaignChoice === "new"
|
|
||||||
? { newCampaignName: newCampaignName || null }
|
|
||||||
: { campaignId: Number(campaignChoice) }
|
|
||||||
: {}),
|
|
||||||
...narrativeInput(),
|
|
||||||
});
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error ?? "Failed to update the side operation.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success("Side operation updated.");
|
|
||||||
onOpenChange(false);
|
|
||||||
onSaved?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await createFridaySideOp({
|
|
||||||
name,
|
|
||||||
codeName,
|
|
||||||
summary,
|
|
||||||
...mapInput(),
|
|
||||||
missionType: missionType as FridaySideOpInput["missionType"],
|
|
||||||
startDateTime: startDateIso,
|
|
||||||
estimatedDuration: Number(estimatedDuration) || 240,
|
|
||||||
maxPlayers: Number(maxPlayers) || 32,
|
|
||||||
...modsInput(),
|
|
||||||
campaignId: campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
|
|
||||||
newCampaignName: campaignChoice === "new" ? newCampaignName || null : null,
|
|
||||||
noCampaign: campaignChoice === "none",
|
|
||||||
...narrativeInput(),
|
|
||||||
});
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error ?? "Failed to schedule the side operation.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success("Side operation scheduled.");
|
|
||||||
onOpenChange(false);
|
|
||||||
onSaved?.();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{isEdit ? "Edit Side Operation" : "Schedule Side Operation"}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isEdit
|
|
||||||
? "Update the details of this Friday side operation."
|
|
||||||
: "Player-run Friday side operations include Discord roll-call and RSVP automatically."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-4 py-2">
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="sideop-name">Mission name</Label>
|
|
||||||
<Input
|
|
||||||
id="sideop-name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Operation Broken Arrow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="sideop-code">Code name</Label>
|
|
||||||
<Input
|
|
||||||
id="sideop-code"
|
|
||||||
value={codeName}
|
|
||||||
onChange={(e) => setCodeName(e.target.value)}
|
|
||||||
placeholder="OP:BRKARW"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="sideop-summary">Summary</Label>
|
|
||||||
<Textarea
|
|
||||||
id="sideop-summary"
|
|
||||||
value={summary}
|
|
||||||
onChange={(e) => setSummary(e.target.value)}
|
|
||||||
placeholder="What is this operation about?"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label>Map</Label>
|
|
||||||
<Select
|
|
||||||
value={mapChoice}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setMapChoice(v);
|
|
||||||
if (v !== "custom" && v !== "") setMapId(v);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Choose a map" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{maps.map((m) => (
|
|
||||||
<SelectItem key={m.id} value={String(m.id)}>
|
|
||||||
{m.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="custom">+ Custom map…</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label>Mission type</Label>
|
|
||||||
<Select value={missionType} onValueChange={setMissionType}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{MISSION_TYPES.map((t) => (
|
|
||||||
<SelectItem key={t} value={t}>
|
|
||||||
{t}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="sideop-duration">Est. duration (minutes)</Label>
|
|
||||||
<Input
|
|
||||||
id="sideop-duration"
|
|
||||||
type="number"
|
|
||||||
min={15}
|
|
||||||
step={15}
|
|
||||||
value={estimatedDuration}
|
|
||||||
onChange={(e) => setEstimatedDuration(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="sideop-players">Max players</Label>
|
|
||||||
<Input
|
|
||||||
id="sideop-players"
|
|
||||||
type="number"
|
|
||||||
min={2}
|
|
||||||
value={maxPlayers}
|
|
||||||
onChange={(e) => setMaxPlayers(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label>Campaign</Label>
|
|
||||||
<Select value={campaignChoice} onValueChange={setCampaignChoice}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Choose a campaign" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">No campaign (one-off op)</SelectItem>
|
|
||||||
{campaigns.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={String(c.id)} disabled={c.disabled}>
|
|
||||||
{c.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="new">+ New campaign…</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{mapChoice === "custom" && (
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="sideop-custom-map">Custom map name</Label>
|
|
||||||
<Input
|
|
||||||
id="sideop-custom-map"
|
|
||||||
value={customMapName}
|
|
||||||
onChange={(e) => setCustomMapName(e.target.value)}
|
|
||||||
placeholder="e.g. Altis Special Branch Edit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{campaignChoice === "new" && (
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="sideop-campaign">New campaign name</Label>
|
|
||||||
<Input
|
|
||||||
id="sideop-campaign"
|
|
||||||
value={newCampaignName}
|
|
||||||
onChange={(e) => setNewCampaignName(e.target.value)}
|
|
||||||
placeholder="Leave empty to use your personal side ops campaign"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label>Mod list</Label>
|
|
||||||
<Select value={requiredModsId} onValueChange={setRequiredModsId}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="No mod list (vanilla)" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">No mod list (vanilla)</SelectItem>
|
|
||||||
{modLists.map((l) => (
|
|
||||||
<SelectItem key={l.id} value={String(l.id)}>
|
|
||||||
{l.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
accept=".html,.htm,text/html"
|
|
||||||
disabled={modlistUploading}
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) void handleModlistUpload(file);
|
|
||||||
e.target.value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Upload the HTML mod list exported from the Arma 3 launcher, or pick an existing one.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapsible open={narrativeOpen} onOpenChange={setNarrativeOpen}>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button type="button" variant="outline" className="w-full justify-between">
|
|
||||||
Narrative & logistics (briefing, objectives, infil/exfil)
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{narrativeOpen ? "Hide" : "Show"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="pt-3">
|
|
||||||
<SideOpNarrativeFields value={narrative} onChange={setNarrative} />
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSubmit} disabled={isPending}>
|
|
||||||
{isPending ? "Saving…" : isEdit ? "Save changes" : "Schedule"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { CalendarDaysIcon, ChevronRightIcon, PlusIcon } from "lucide-react";
|
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { CreateSideOpDialog, type SideOpDialogOption } from "./CreateSideOpDialog";
|
|
||||||
|
|
||||||
export interface FridayCalendarMission {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
codeName: string;
|
|
||||||
status: string;
|
|
||||||
startDateTime: string;
|
|
||||||
operationType: string;
|
|
||||||
mapName: string | null;
|
|
||||||
isOwn: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FridayCalendarEntry {
|
|
||||||
fridayIso: string;
|
|
||||||
missions: FridayCalendarMission[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FridayCalendarProps {
|
|
||||||
entries: FridayCalendarEntry[];
|
|
||||||
remainingSlots: number;
|
|
||||||
maxSlots: number;
|
|
||||||
maps: SideOpDialogOption[];
|
|
||||||
campaigns: SideOpDialogOption[];
|
|
||||||
modLists: SideOpDialogOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
Concept: "bg-muted text-muted-foreground",
|
|
||||||
Planning: "bg-muted text-muted-foreground",
|
|
||||||
Ready: "bg-blue-500/20 text-blue-400",
|
|
||||||
Scheduled: "bg-amber-500/20 text-amber-400",
|
|
||||||
Active: "bg-green-500/20 text-green-400",
|
|
||||||
Completed: "bg-emerald-500/20 text-emerald-400",
|
|
||||||
Cancelled: "bg-red-500/20 text-red-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatFriday(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleDateString(undefined, {
|
|
||||||
weekday: "long",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleTimeString(undefined, {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultStartForFriday(fridayIso: string): string {
|
|
||||||
const date = new Date(fridayIso);
|
|
||||||
date.setHours(20, 0, 0, 0);
|
|
||||||
return date.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FridayCalendar({
|
|
||||||
entries,
|
|
||||||
remainingSlots,
|
|
||||||
maxSlots,
|
|
||||||
maps,
|
|
||||||
campaigns,
|
|
||||||
modLists,
|
|
||||||
}: FridayCalendarProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [scheduleFor, setScheduleFor] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const upcoming = useMemo(() => entries, [entries]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Friday Operations</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Schedule and manage player-run Friday side operations. Each Friday gets Discord
|
|
||||||
roll-call and RSVP automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`rounded-md border px-2.5 py-1 text-xs font-medium ${
|
|
||||||
remainingSlots > 0
|
|
||||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
|
||||||
: "border-red-500/30 bg-red-500/10 text-red-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{remainingSlots > 0
|
|
||||||
? `You can schedule ${remainingSlots} more mission${remainingSlots === 1 ? "" : "s"}`
|
|
||||||
: `Scheduling limit reached (${maxSlots} ahead)`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{upcoming.map((entry) => {
|
|
||||||
const fridayTaken = entry.missions.some(
|
|
||||||
(m) => m.status !== "Completed" && m.status !== "Cancelled",
|
|
||||||
);
|
|
||||||
const canSchedule = remainingSlots > 0 && !fridayTaken;
|
|
||||||
return (
|
|
||||||
<Item key={entry.fridayIso} variant="outline">
|
|
||||||
<ItemHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarDaysIcon className="size-4 text-muted-foreground" />
|
|
||||||
<ItemTitle>{formatFriday(entry.fridayIso)}</ItemTitle>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={!canSchedule}
|
|
||||||
onClick={() => setScheduleFor(entry.fridayIso)}
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-3.5" />
|
|
||||||
{fridayTaken ? "Taken" : "Schedule"}
|
|
||||||
</Button>
|
|
||||||
</ItemHeader>
|
|
||||||
<ItemContent>
|
|
||||||
{entry.missions.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Nothing scheduled yet — claim this Friday.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{entry.missions.map((m) => (
|
|
||||||
<Link
|
|
||||||
key={m.id}
|
|
||||||
href={`/intelligence/missions/${m.id}`}
|
|
||||||
className="group flex items-center gap-3 rounded-md p-1.5 transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<span className="w-14 shrink-0 font-mono text-xs text-muted-foreground">
|
|
||||||
{formatTime(m.startDateTime)}
|
|
||||||
</span>
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
<span className="truncate text-sm transition-colors group-hover:text-primary">
|
|
||||||
{m.name}
|
|
||||||
</span>
|
|
||||||
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
|
||||||
{m.codeName}
|
|
||||||
{m.mapName ? ` · ${m.mapName}` : ""}
|
|
||||||
{m.isOwn ? " · yours" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
|
|
||||||
statusColors[m.status] ?? "bg-muted text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m.status}
|
|
||||||
</span>
|
|
||||||
<ChevronRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Fridays beyond this date are unavailable for scheduling via the web app.<br/><br/>If you want to schedule an
|
|
||||||
op further out than the dates listed here, please contact <b>Jason (Z8MB1E)</b> directly.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CreateSideOpDialog
|
|
||||||
open={scheduleFor !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setScheduleFor(null);
|
|
||||||
}}
|
|
||||||
maps={maps}
|
|
||||||
campaigns={campaigns}
|
|
||||||
modLists={modLists}
|
|
||||||
defaultStartDateTime={scheduleFor ? defaultStartForFriday(scheduleFor) : undefined}
|
|
||||||
onSaved={() => router.refresh()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -37,7 +37,7 @@ export interface MissionCardProps {
|
||||||
readonly startDateTime: string;
|
readonly startDateTime: string;
|
||||||
readonly estimatedDuration: number;
|
readonly estimatedDuration: number;
|
||||||
readonly map: number | Map;
|
readonly map: number | Map;
|
||||||
readonly campaign?: number | Campaign | null;
|
readonly campaign: number | Campaign;
|
||||||
};
|
};
|
||||||
readonly ownershipAndStatus: {
|
readonly ownershipAndStatus: {
|
||||||
readonly status:
|
readonly status:
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { PencilIcon, XCircleIcon } from "lucide-react";
|
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cancelFridaySideOp } from "@/app/(frontend)/intelligence/missions/actions";
|
|
||||||
import {
|
|
||||||
CreateSideOpDialog,
|
|
||||||
type SideOpDialogInitial,
|
|
||||||
type SideOpDialogOption,
|
|
||||||
} from "./CreateSideOpDialog";
|
|
||||||
|
|
||||||
interface SideOpControlsProps {
|
|
||||||
initial: SideOpDialogInitial;
|
|
||||||
status: string;
|
|
||||||
maps: SideOpDialogOption[];
|
|
||||||
campaigns: SideOpDialogOption[];
|
|
||||||
modLists: SideOpDialogOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SideOpControls({
|
|
||||||
initial,
|
|
||||||
status,
|
|
||||||
maps,
|
|
||||||
campaigns,
|
|
||||||
modLists,
|
|
||||||
}: SideOpControlsProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
|
||||||
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const isTerminal = status === "Completed" || status === "Cancelled";
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
if (!confirmingCancel) {
|
|
||||||
setConfirmingCancel(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await cancelFridaySideOp(initial.missionId);
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error(result.error ?? "Failed to cancel the side operation.");
|
|
||||||
setConfirmingCancel(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success("Side operation cancelled. Discord roll-call will be updated.");
|
|
||||||
setConfirmingCancel(false);
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTerminal) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Item variant="outline">
|
|
||||||
<ItemHeader>
|
|
||||||
<ItemTitle>Side Operation Controls</ItemTitle>
|
|
||||||
</ItemHeader>
|
|
||||||
<ItemContent>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
|
|
||||||
<PencilIcon className="size-3.5" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={confirmingCancel ? "destructive" : "outline"}
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<XCircleIcon className="size-3.5" />
|
|
||||||
{confirmingCancel ? "Confirm cancel?" : "Cancel mission"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Editing covers details, campaign, and narrative. Cancelling frees your scheduling slot
|
|
||||||
and updates the Discord embed.
|
|
||||||
</p>
|
|
||||||
</ItemContent>
|
|
||||||
|
|
||||||
<CreateSideOpDialog
|
|
||||||
open={editOpen}
|
|
||||||
onOpenChange={setEditOpen}
|
|
||||||
maps={maps}
|
|
||||||
campaigns={campaigns}
|
|
||||||
modLists={modLists}
|
|
||||||
initial={initial}
|
|
||||||
onSaved={() => router.refresh()}
|
|
||||||
/>
|
|
||||||
</Item>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,352 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
const CIVILIAN_PRESENCE = ["None", "Low", "Medium", "High"] as const;
|
|
||||||
|
|
||||||
const INFIL_EXFIL_METHODS = [
|
|
||||||
{ value: "helicopter", label: "Helicopter" },
|
|
||||||
{ value: "aircraft", label: "Aircraft" },
|
|
||||||
{ value: "paradrop", label: "HALO / Paradrop" },
|
|
||||||
{ value: "boat", label: "Boat" },
|
|
||||||
{ value: "car", label: "Car" },
|
|
||||||
{ value: "onFoot", label: "On Foot" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export interface SideOpNarrative {
|
|
||||||
briefing: { sectionName: string; details: string }[];
|
|
||||||
missionObjectives: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
missionCritical: boolean;
|
|
||||||
successOrFailConditions: string;
|
|
||||||
redacted: boolean;
|
|
||||||
}[];
|
|
||||||
civilianPresence: string;
|
|
||||||
radioPlan: string;
|
|
||||||
insertion: string[];
|
|
||||||
extraction: string[];
|
|
||||||
assetsAvailable: {
|
|
||||||
assetType: string;
|
|
||||||
assetCount: string;
|
|
||||||
doesRespawn: boolean;
|
|
||||||
assetCallsign: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SideOpNarrativeFieldsProps {
|
|
||||||
value: SideOpNarrative;
|
|
||||||
onChange: (next: SideOpNarrative) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleValue(list: string[], value: string): string[] {
|
|
||||||
return list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFieldsProps) {
|
|
||||||
const setBriefing = (briefing: SideOpNarrative["briefing"]) => onChange({ ...value, briefing });
|
|
||||||
const setObjectives = (missionObjectives: SideOpNarrative["missionObjectives"]) =>
|
|
||||||
onChange({ ...value, missionObjectives });
|
|
||||||
const setAssets = (assetsAvailable: SideOpNarrative["assetsAvailable"]) =>
|
|
||||||
onChange({ ...value, assetsAvailable });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-5">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Plain text only — each line becomes a paragraph. Rich formatting written in the admin panel
|
|
||||||
is flattened if you save here.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>Briefing entries</Label>
|
|
||||||
{value.briefing.map((entry, i) => (
|
|
||||||
<div key={i} className="grid gap-1.5 rounded-md border p-2.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={entry.sectionName}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...value.briefing];
|
|
||||||
next[i] = { ...entry, sectionName: e.target.value };
|
|
||||||
setBriefing(next);
|
|
||||||
}}
|
|
||||||
placeholder="Section name (e.g. Situation)"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Remove briefing entry"
|
|
||||||
onClick={() => setBriefing(value.briefing.filter((_, j) => j !== i))}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
value={entry.details}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...value.briefing];
|
|
||||||
next[i] = { ...entry, details: e.target.value };
|
|
||||||
setBriefing(next);
|
|
||||||
}}
|
|
||||||
placeholder="Briefing text for this section"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{value.briefing.length < 8 && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-fit"
|
|
||||||
onClick={() =>
|
|
||||||
setBriefing([...value.briefing, { sectionName: "", details: "" }])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" /> Add briefing entry
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>Mission objectives</Label>
|
|
||||||
{value.missionObjectives.map((obj, i) => (
|
|
||||||
<div key={i} className="grid gap-1.5 rounded-md border p-2.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={obj.title}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...value.missionObjectives];
|
|
||||||
next[i] = { ...obj, title: e.target.value };
|
|
||||||
setObjectives(next);
|
|
||||||
}}
|
|
||||||
placeholder="Objective title"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Remove objective"
|
|
||||||
onClick={() => setObjectives(value.missionObjectives.filter((_, j) => j !== i))}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
value={obj.description}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...value.missionObjectives];
|
|
||||||
next[i] = { ...obj, description: e.target.value };
|
|
||||||
setObjectives(next);
|
|
||||||
}}
|
|
||||||
placeholder="Description"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={obj.successOrFailConditions}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...value.missionObjectives];
|
|
||||||
next[i] = { ...obj, successOrFailConditions: e.target.value };
|
|
||||||
setObjectives(next);
|
|
||||||
}}
|
|
||||||
placeholder="Success / fail conditions"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<Checkbox
|
|
||||||
checked={obj.missionCritical}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
const next = [...value.missionObjectives];
|
|
||||||
next[i] = { ...obj, missionCritical: checked === true };
|
|
||||||
setObjectives(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Mission critical
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<Checkbox
|
|
||||||
checked={obj.redacted}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
const next = [...value.missionObjectives];
|
|
||||||
next[i] = { ...obj, redacted: checked === true };
|
|
||||||
setObjectives(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Redacted
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-fit"
|
|
||||||
onClick={() =>
|
|
||||||
setObjectives([
|
|
||||||
...value.missionObjectives,
|
|
||||||
{
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
missionCritical: true,
|
|
||||||
successOrFailConditions: "",
|
|
||||||
redacted: false,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" /> Add objective
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>Civilian presence</Label>
|
|
||||||
<Select
|
|
||||||
value={value.civilianPresence}
|
|
||||||
onValueChange={(civilianPresence) => onChange({ ...value, civilianPresence })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{CIVILIAN_PRESENCE.map((c) => (
|
|
||||||
<SelectItem key={c} value={c}>
|
|
||||||
{c}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>Radio SOP</Label>
|
|
||||||
<Textarea
|
|
||||||
value={value.radioPlan}
|
|
||||||
onChange={(e) => onChange({ ...value, radioPlan: e.target.value })}
|
|
||||||
placeholder="How radios will be used: nets, contingency plans, radio silence, etc."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>Infiltration methods</Label>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
{INFIL_EXFIL_METHODS.map((m) => (
|
|
||||||
<label key={m.value} className="flex items-center gap-2 text-sm">
|
|
||||||
<Checkbox
|
|
||||||
checked={value.insertion.includes(m.value)}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
onChange({ ...value, insertion: toggleValue(value.insertion, m.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{m.label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>Exfiltration methods</Label>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
{INFIL_EXFIL_METHODS.map((m) => (
|
|
||||||
<label key={m.value} className="flex items-center gap-2 text-sm">
|
|
||||||
<Checkbox
|
|
||||||
checked={value.extraction.includes(m.value)}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
onChange({ ...value, extraction: toggleValue(value.extraction, m.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{m.label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>Logistics & support assets</Label>
|
|
||||||
{value.assetsAvailable.map((asset, i) => (
|
|
||||||
<div key={i} className="grid gap-1.5 rounded-md border p-2.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={asset.assetType}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...value.assetsAvailable];
|
|
||||||
next[i] = { ...asset, assetType: e.target.value };
|
|
||||||
setAssets(next);
|
|
||||||
}}
|
|
||||||
placeholder="Asset type (e.g. Artillery, Fuel Truck, CAS)"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Remove asset"
|
|
||||||
onClick={() => setAssets(value.assetsAvailable.filter((_, j) => j !== i))}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Input
|
|
||||||
value={asset.assetCount}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...value.assetsAvailable];
|
|
||||||
next[i] = { ...asset, assetCount: e.target.value };
|
|
||||||
setAssets(next);
|
|
||||||
}}
|
|
||||||
placeholder="Count"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={asset.assetCallsign}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...value.assetsAvailable];
|
|
||||||
next[i] = { ...asset, assetCallsign: e.target.value };
|
|
||||||
setAssets(next);
|
|
||||||
}}
|
|
||||||
placeholder="Callsign"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<Checkbox
|
|
||||||
checked={asset.doesRespawn}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
const next = [...value.assetsAvailable];
|
|
||||||
next[i] = { ...asset, doesRespawn: checked === true };
|
|
||||||
setAssets(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Respawnable
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-fit"
|
|
||||||
onClick={() =>
|
|
||||||
setAssets([
|
|
||||||
...value.assetsAvailable,
|
|
||||||
{ assetType: "", assetCount: "", doesRespawn: false, assetCallsign: "" },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" /> Add asset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
interface LexicalTextNode {
|
|
||||||
type: string;
|
|
||||||
text?: unknown;
|
|
||||||
children?: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LexicalBlock {
|
|
||||||
type: string;
|
|
||||||
children?: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SerializedLexicalState = {
|
|
||||||
root: {
|
|
||||||
type: "root";
|
|
||||||
children: {
|
|
||||||
type: "paragraph";
|
|
||||||
children: { type: "text"; text: string; version: number }[];
|
|
||||||
direction: null;
|
|
||||||
format: "";
|
|
||||||
indent: number;
|
|
||||||
version: number;
|
|
||||||
}[];
|
|
||||||
direction: "ltr" | "rtl" | null;
|
|
||||||
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
|
|
||||||
indent: number;
|
|
||||||
version: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function collectText(node: unknown): string {
|
|
||||||
if (typeof node !== "object" || node === null) return "";
|
|
||||||
const n = node as LexicalTextNode;
|
|
||||||
if (n.type === "linebreak") return "\n";
|
|
||||||
if (typeof n.text === "string") return n.text;
|
|
||||||
if (!Array.isArray(n.children)) return "";
|
|
||||||
return n.children.map(collectText).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectBlockText(node: unknown): string {
|
|
||||||
if (typeof node !== "object" || node === null) return "";
|
|
||||||
const n = node as LexicalBlock;
|
|
||||||
if (!Array.isArray(n.children)) return "";
|
|
||||||
return n.children.map(collectText).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function lexicalToText(details: unknown): string {
|
|
||||||
if (typeof details !== "object" || details === null) return "";
|
|
||||||
const root = (details as SerializedLexicalState).root;
|
|
||||||
if (!root || !Array.isArray(root.children)) return "";
|
|
||||||
return root.children
|
|
||||||
.map(collectBlockText)
|
|
||||||
.filter((line, i, all) => line.trim() !== "" || (i > 0 && i < all.length - 1))
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function textToLexical(text: string): SerializedLexicalState {
|
|
||||||
const lines = text.split("\n");
|
|
||||||
return {
|
|
||||||
root: {
|
|
||||||
type: "root",
|
|
||||||
children: (lines.length > 0 ? lines : [""]).map((line) => ({
|
|
||||||
type: "paragraph",
|
|
||||||
children:
|
|
||||||
line === ""
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: line,
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
direction: null,
|
|
||||||
format: "",
|
|
||||||
indent: 0,
|
|
||||||
version: 1,
|
|
||||||
})),
|
|
||||||
direction: "ltr",
|
|
||||||
format: "",
|
|
||||||
indent: 0,
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -128,7 +128,7 @@ export const wikiMarkdownComponents: Components = {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} data-directive={directiveName}>
|
<div className={className} data-directive={directiveName}>
|
||||||
{isCallout && <Icon className="callout-icon size-4 shrink-0" aria-hidden="true" />}
|
{isCallout && <Icon className="callout-icon mt-0.5 size-4 shrink-0" aria-hidden="true" />}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
|
||||||
|
|
||||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "missions" ADD COLUMN "created_by_id" integer;
|
|
||||||
ALTER TABLE "missions" ADD CONSTRAINT "missions_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
CREATE INDEX "missions_created_by_idx" ON "missions" USING btree ("created_by_id");`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "missions" DROP CONSTRAINT "missions_created_by_id_users_id_fk";
|
|
||||||
|
|
||||||
DROP INDEX "missions_created_by_idx";
|
|
||||||
ALTER TABLE "missions" DROP COLUMN "created_by_id";`)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
|
||||||
|
|
||||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "missions" ALTER COLUMN "classification_campaign_id" DROP NOT NULL;`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "missions" ALTER COLUMN "classification_campaign_id" SET NOT NULL;`)
|
|
||||||
}
|
|
||||||
|
|
@ -35,8 +35,6 @@ import * as migration_20260906_065405_add_wiki_template_html from './20260906_06
|
||||||
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
|
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
|
||||||
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
|
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
|
||||||
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
|
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
|
||||||
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by';
|
|
||||||
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional';
|
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -222,16 +220,6 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260906_202925_add_voice_submissions.up,
|
up: migration_20260906_202925_add_voice_submissions.up,
|
||||||
down: migration_20260906_202925_add_voice_submissions.down,
|
down: migration_20260906_202925_add_voice_submissions.down,
|
||||||
name: '20260906_202925_add_voice_submissions',
|
name: '20260906_202925_add_voice_submissions'
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260907_012326_add_missions_created_by.up,
|
|
||||||
down: migration_20260907_012326_add_missions_created_by.down,
|
|
||||||
name: '20260907_012326_add_missions_created_by',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260907_013724_make_missions_campaign_optional.up,
|
|
||||||
down: migration_20260907_013724_make_missions_campaign_optional.down,
|
|
||||||
name: '20260907_013724_make_missions_campaign_optional'
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2376,10 +2376,6 @@ export interface Mission {
|
||||||
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
|
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
|
||||||
*/
|
*/
|
||||||
evaluationRemindersSentAt?: string | null;
|
evaluationRemindersSentAt?: string | null;
|
||||||
/**
|
|
||||||
* Player who scheduled this mission via the Friday Ops calendar (ownership + scheduling-cap tracking). Set automatically; leave empty for staff-created missions.
|
|
||||||
*/
|
|
||||||
createdBy?: (number | null) | User;
|
|
||||||
/**
|
/**
|
||||||
* Background image for the mission page hero header. Renders translucent with a top-to-bottom fade to black. Recommended aspect ratio: 16:9 or wider.
|
* Background image for the mission page hero header. Renders translucent with a top-to-bottom fade to black. Recommended aspect ratio: 16:9 or wider.
|
||||||
*/
|
*/
|
||||||
|
|
@ -2387,7 +2383,7 @@ export interface Mission {
|
||||||
classification: {
|
classification: {
|
||||||
map: number | Map;
|
map: number | Map;
|
||||||
missionType: 'Zeus' | 'PvE' | 'PvP' | 'PvPvE' | 'Training' | 'Special';
|
missionType: 'Zeus' | 'PvE' | 'PvP' | 'PvPvE' | 'Training' | 'Special';
|
||||||
campaign?: (number | null) | Campaign;
|
campaign: number | Campaign;
|
||||||
/**
|
/**
|
||||||
* This mission contains custom SQF scripts.
|
* This mission contains custom SQF scripts.
|
||||||
*/
|
*/
|
||||||
|
|
@ -4472,7 +4468,6 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
discordAttendanceHash?: T;
|
discordAttendanceHash?: T;
|
||||||
discordAttendanceSentAt?: T;
|
discordAttendanceSentAt?: T;
|
||||||
evaluationRemindersSentAt?: T;
|
evaluationRemindersSentAt?: T;
|
||||||
createdBy?: T;
|
|
||||||
coverImage?: T;
|
coverImage?: T;
|
||||||
classification?:
|
classification?:
|
||||||
| T
|
| T
|
||||||
|
|
|
||||||
|
|
@ -125,10 +125,6 @@ export const EventTypes = {
|
||||||
// Missions
|
// Missions
|
||||||
MissionAutoComplete: "mission:auto-complete",
|
MissionAutoComplete: "mission:auto-complete",
|
||||||
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
||||||
MissionSchedule: "mission:schedule",
|
|
||||||
MissionUpdate: "mission:update",
|
|
||||||
MissionCancel: "mission:cancel",
|
|
||||||
MissionMoveCampaign: "mission:move-campaign",
|
|
||||||
|
|
||||||
// Combat
|
// Combat
|
||||||
PlayerKill: "combat:kill",
|
PlayerKill: "combat:kill",
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,7 @@ export type ResolvedLevel = {
|
||||||
* current bracket: (xp - currentLevelXP) / (nextLevelXP - currentLevelXP).
|
* current bracket: (xp - currentLevelXP) / (nextLevelXP - currentLevelXP).
|
||||||
* At the max level the bar sits at 100%.
|
* At the max level the bar sits at 100%.
|
||||||
*/
|
*/
|
||||||
export function resolveLevel(rawXp: number, levels: ExperienceLevel[]): ResolvedLevel {
|
export function resolveLevel(xp: number, levels: ExperienceLevel[]): ResolvedLevel {
|
||||||
const xp = Math.floor(rawXp);
|
|
||||||
|
|
||||||
if (levels.length === 0) {
|
if (levels.length === 0) {
|
||||||
return {
|
return {
|
||||||
levelName: "Unknown",
|
levelName: "Unknown",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue