feat(missions): make code name optional, add UGC cover images
- Missions.codeName is now optional (still unique when set); migration included - Campaign and side-op dialogs accept an optional cover image (uploadUgcImage) - Reminder toasts, evaluation aggregates, calendar, cards and mission headers handle a null code name - Mission read filter no longer hides drafts by status; visibility follows ownershipAndStatus.visibility only
This commit is contained in:
parent
2ed90d9f09
commit
7180b0aa4c
16 changed files with 24739 additions and 66 deletions
|
|
@ -294,10 +294,12 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
{operationTypeLabels[mission.operationType] ?? mission.operationType}
|
{operationTypeLabels[mission.operationType] ?? mission.operationType}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{mission.codeName && (
|
||||||
<div className="flex items-center gap-2 font-mono text-sm uppercase tracking-widest text-white/60">
|
<div className="flex items-center gap-2 font-mono text-sm uppercase tracking-widest text-white/60">
|
||||||
<CrosshairIcon className="size-4" />
|
<CrosshairIcon className="size-4" />
|
||||||
<span>{mission.codeName}</span>
|
<span>{mission.codeName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<h1
|
<h1
|
||||||
id="mission-title"
|
id="mission-title"
|
||||||
className="mt-3 text-4xl font-bold tracking-tight text-white drop-shadow-2xl md:text-6xl"
|
className="mt-3 text-4xl font-bold tracking-tight text-white drop-shadow-2xl md:text-6xl"
|
||||||
|
|
@ -438,6 +440,8 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
name: mission.name,
|
name: mission.name,
|
||||||
codeName: mission.codeName,
|
codeName: mission.codeName,
|
||||||
summary: mission.summary,
|
summary: mission.summary,
|
||||||
|
coverImageId: coverImage?.id ?? null,
|
||||||
|
coverImageUrl: coverImageUrl ?? null,
|
||||||
mapId:
|
mapId:
|
||||||
typeof mission.classification.map === "object"
|
typeof mission.classification.map === "object"
|
||||||
? mission.classification.map.id
|
? mission.classification.map.id
|
||||||
|
|
@ -540,9 +544,11 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
className="flex items-center gap-3 p-1.5 rounded-md hover:bg-muted/50 transition-colors group"
|
className="flex items-center gap-3 p-1.5 rounded-md hover:bg-muted/50 transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
{m.codeName && (
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
{m.codeName}
|
{m.codeName}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-sm truncate group-hover:text-primary transition-colors">
|
<span className="text-sm truncate group-hover:text-primary transition-colors">
|
||||||
{m.name}
|
{m.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -247,10 +247,15 @@ export async function uploadSideOpModlist(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function missionRef(mission: { name: string; codeName?: string | null }): string {
|
||||||
|
return mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FridaySideOpInput {
|
export interface FridaySideOpInput {
|
||||||
name: string;
|
name: string;
|
||||||
codeName: string;
|
codeName?: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
coverImageId?: number | null;
|
||||||
mapId?: number;
|
mapId?: number;
|
||||||
missionType: (typeof MISSION_TYPES)[number];
|
missionType: (typeof MISSION_TYPES)[number];
|
||||||
startDateTime: string;
|
startDateTime: string;
|
||||||
|
|
@ -343,7 +348,7 @@ async function findActiveMissionOnFriday(
|
||||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
payload: Awaited<ReturnType<typeof getPayload>>,
|
||||||
startDateTime: string,
|
startDateTime: string,
|
||||||
excludeMissionId?: number,
|
excludeMissionId?: number,
|
||||||
): Promise<{ id: number; name: string; codeName: string } | null> {
|
): Promise<{ id: number; name: string; codeName?: string | null } | null> {
|
||||||
const start = new Date(fridayStartDateTime(startDateTime));
|
const start = new Date(fridayStartDateTime(startDateTime));
|
||||||
const key = zonedDateKey(start);
|
const key = zonedDateKey(start);
|
||||||
const { year, month, day } = parseDateKey(key);
|
const { year, month, day } = parseDateKey(key);
|
||||||
|
|
@ -411,8 +416,8 @@ export async function createFridaySideOp(
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
const userId = user.id as number;
|
const userId = user.id as number;
|
||||||
|
|
||||||
if (!input.name?.trim() || !input.summary?.trim() || !input.codeName?.trim()) {
|
if (!input.name?.trim() || !input.summary?.trim()) {
|
||||||
return { success: false, error: "Name, code name, and summary are required." };
|
return { success: false, error: "Name and summary are required." };
|
||||||
}
|
}
|
||||||
if (!input.mapId && !input.customMapName?.trim()) {
|
if (!input.mapId && !input.customMapName?.trim()) {
|
||||||
return { success: false, error: "Please choose a map." };
|
return { success: false, error: "Please choose a map." };
|
||||||
|
|
@ -446,7 +451,7 @@ export async function createFridaySideOp(
|
||||||
if (fridayConflict) {
|
if (fridayConflict) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `That Friday is already taken by "${fridayConflict.name}" (${fridayConflict.codeName}). Only one operation per Friday, first come first served.`,
|
error: `That Friday is already taken by "${missionRef(fridayConflict)}". Only one operation per Friday, first come first served.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,9 +479,10 @@ export async function createFridaySideOp(
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
data: {
|
data: {
|
||||||
name: input.name.trim(),
|
name: input.name.trim(),
|
||||||
codeName: input.codeName.trim(),
|
...(input.codeName?.trim() ? { codeName: input.codeName.trim() } : {}),
|
||||||
summary: input.summary.trim(),
|
summary: input.summary.trim(),
|
||||||
operationType: "side",
|
operationType: "side",
|
||||||
|
...(input.coverImageId ? { coverImage: input.coverImageId } : {}),
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
ownershipAndStatus: {
|
ownershipAndStatus: {
|
||||||
status: "Scheduled",
|
status: "Scheduled",
|
||||||
|
|
@ -509,7 +515,7 @@ export async function createFridaySideOp(
|
||||||
const startIso = fridayStartDateTime(input.startDateTime);
|
const startIso = fridayStartDateTime(input.startDateTime);
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.MissionSchedule,
|
type: EventTypes.MissionSchedule,
|
||||||
message: `${user.displayName || user.username} scheduled side operation "${mission.name}" (${mission.codeName}) for ${new Date(startIso).toLocaleString()}.`,
|
message: `${user.displayName || user.username} scheduled side operation "${missionRef(mission)}" for ${new Date(startIso).toLocaleString()}.`,
|
||||||
actor: userId,
|
actor: userId,
|
||||||
targetCollection: "missions",
|
targetCollection: "missions",
|
||||||
targetId: mission.id,
|
targetId: mission.id,
|
||||||
|
|
@ -530,6 +536,7 @@ export interface UpdateFridaySideOpInput {
|
||||||
name?: string;
|
name?: string;
|
||||||
codeName?: string;
|
codeName?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
coverImageId?: number | null;
|
||||||
mapId?: number;
|
mapId?: number;
|
||||||
missionType?: (typeof MISSION_TYPES)[number];
|
missionType?: (typeof MISSION_TYPES)[number];
|
||||||
startDateTime?: string;
|
startDateTime?: string;
|
||||||
|
|
@ -629,15 +636,17 @@ export async function updateFridaySideOp(
|
||||||
const classification: SideOpClassificationUpdate = {};
|
const classification: SideOpClassificationUpdate = {};
|
||||||
const data: {
|
const data: {
|
||||||
name?: string;
|
name?: string;
|
||||||
codeName?: string;
|
codeName?: string | null;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
coverImage?: number | null;
|
||||||
classification?: SideOpClassificationUpdate;
|
classification?: SideOpClassificationUpdate;
|
||||||
missionRoles?: { maxPlayers?: number };
|
missionRoles?: { maxPlayers?: number };
|
||||||
gameDetails?: { gameSetup?: { requiredMods?: number | null } };
|
gameDetails?: { gameSetup?: { requiredMods?: number | null } };
|
||||||
} = {};
|
} = {};
|
||||||
if (input.name !== undefined) data.name = input.name.trim();
|
if (input.name !== undefined) data.name = input.name.trim();
|
||||||
if (input.codeName !== undefined) data.codeName = input.codeName.trim();
|
if (input.codeName !== undefined) data.codeName = input.codeName.trim() || null;
|
||||||
if (input.summary !== undefined) data.summary = input.summary.trim();
|
if (input.summary !== undefined) data.summary = input.summary.trim();
|
||||||
|
if (input.coverImageId !== undefined) data.coverImage = input.coverImageId;
|
||||||
if (input.estimatedDuration !== undefined) {
|
if (input.estimatedDuration !== undefined) {
|
||||||
classification.estimatedDuration = input.estimatedDuration;
|
classification.estimatedDuration = input.estimatedDuration;
|
||||||
}
|
}
|
||||||
|
|
@ -680,7 +689,7 @@ export async function updateFridaySideOp(
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.MissionUpdate,
|
type: EventTypes.MissionUpdate,
|
||||||
message: `${user.displayName || user.username} updated side operation "${updated.name}" (${updated.codeName}).`,
|
message: `${user.displayName || user.username} updated side operation "${missionRef(updated)}".`,
|
||||||
actor: userId,
|
actor: userId,
|
||||||
targetCollection: "missions",
|
targetCollection: "missions",
|
||||||
targetId: updated.id,
|
targetId: updated.id,
|
||||||
|
|
@ -739,7 +748,7 @@ export async function cancelFridaySideOp(
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.MissionCancel,
|
type: EventTypes.MissionCancel,
|
||||||
message: `${user.displayName || user.username} cancelled side operation "${updated.name}" (${updated.codeName}).`,
|
message: `${user.displayName || user.username} cancelled side operation "${missionRef(updated)}".`,
|
||||||
actor: userId,
|
actor: userId,
|
||||||
targetCollection: "missions",
|
targetCollection: "missions",
|
||||||
targetId: updated.id,
|
targetId: updated.id,
|
||||||
|
|
@ -803,7 +812,7 @@ export async function markSideOpReady(
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.MissionReady,
|
type: EventTypes.MissionReady,
|
||||||
message: `${user.displayName || user.username} marked side operation "${updated.name}" (${updated.codeName}) as Ready. Details are locked in and the roll-call can be posted.`,
|
message: `${user.displayName || user.username} marked side operation "${missionRef(updated)}" as Ready. Details are locked in and the roll-call can be posted.`,
|
||||||
actor: userId,
|
actor: userId,
|
||||||
targetCollection: "missions",
|
targetCollection: "missions",
|
||||||
targetId: updated.id,
|
targetId: updated.id,
|
||||||
|
|
@ -864,7 +873,7 @@ export async function moveFridaySideOpToCampaign(
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.MissionMoveCampaign,
|
type: EventTypes.MissionMoveCampaign,
|
||||||
message: `${user.displayName || user.username} moved side operation "${mission.name}" (${mission.codeName}) to campaign "${campaign.name}".`,
|
message: `${user.displayName || user.username} moved side operation "${missionRef(mission)}" to campaign "${campaign.name}".`,
|
||||||
actor: userId,
|
actor: userId,
|
||||||
targetCollection: "missions",
|
targetCollection: "missions",
|
||||||
targetId: missionId,
|
targetId: missionId,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import { requirePermission, hasPermission, isSuperuser } from "@/utils/access-co
|
||||||
|
|
||||||
type AssignmentRef = { id: number } | number;
|
type AssignmentRef = { id: number } | number;
|
||||||
|
|
||||||
const DRAFT_STATUSES = ["Concept", "Planning"];
|
|
||||||
|
|
||||||
async function isInAssignments(
|
async function isInAssignments(
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|
@ -38,15 +36,14 @@ function extractIds(refs: unknown[] | undefined): number[] {
|
||||||
* Per-document read access for missions, expressed as a Payload Where
|
* Per-document read access for missions, expressed as a Payload Where
|
||||||
* constraint so it applies to both list queries and single-doc reads.
|
* constraint so it applies to both list queries and single-doc reads.
|
||||||
*
|
*
|
||||||
* Visibility rules (per ownershipAndStatus.visibility):
|
* The ownershipAndStatus.visibility field is the only gate (plus
|
||||||
* - authors / zeus always see their own missions.
|
* author/zeus ownership and the superuser bypass); mission status
|
||||||
|
* never affects who can see a mission:
|
||||||
* - unit: every authenticated user.
|
* - unit: every authenticated user.
|
||||||
* - leadership: command or intel assignment members (+ authors/zeus).
|
* - leadership: command or intel assignment members (+ authors/zeus).
|
||||||
* - intel: intel assignment members (+ authors/zeus).
|
* - intel: intel assignment members (+ authors/zeus).
|
||||||
* - private: authors/zeus only.
|
* - private: authors/zeus only.
|
||||||
*
|
* - authors / zeus always see their own missions.
|
||||||
* Drafts (Concept / Planning) are only visible to authors/zeus and
|
|
||||||
* command/intel members. Superusers bypass everything.
|
|
||||||
*/
|
*/
|
||||||
async function missionReadFilter(req: PayloadRequest): Promise<boolean | Where> {
|
async function missionReadFilter(req: PayloadRequest): Promise<boolean | Where> {
|
||||||
if (!req.user) return false;
|
if (!req.user) return false;
|
||||||
|
|
@ -65,20 +62,17 @@ async function missionReadFilter(req: PayloadRequest): Promise<boolean | Where>
|
||||||
|
|
||||||
const isLeader = isCommand || isIntel;
|
const isLeader = isCommand || isIntel;
|
||||||
|
|
||||||
const notDraft = { "ownershipAndStatus.status": { not_in: DRAFT_STATUSES } };
|
|
||||||
|
|
||||||
const or: Where["or"] = [
|
const or: Where["or"] = [
|
||||||
{ "ownershipAndStatus.authors": { in: [userId] } },
|
{ "ownershipAndStatus.authors": { in: [userId] } },
|
||||||
{ "ownershipAndStatus.zeus": { in: [userId] } },
|
{ "ownershipAndStatus.zeus": { in: [userId] } },
|
||||||
{ and: [notDraft, { "ownershipAndStatus.visibility": { equals: "unit" } }] },
|
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLeader) {
|
if (isLeader) {
|
||||||
or.push({ and: [notDraft, { "ownershipAndStatus.visibility": { equals: "leadership" } }] });
|
or.push({ "ownershipAndStatus.visibility": { equals: "leadership" } });
|
||||||
or.push({ "ownershipAndStatus.status": { in: DRAFT_STATUSES } });
|
|
||||||
}
|
}
|
||||||
if (isIntel) {
|
if (isIntel) {
|
||||||
or.push({ and: [notDraft, { "ownershipAndStatus.visibility": { equals: "intel" } }] });
|
or.push({ "ownershipAndStatus.visibility": { equals: "intel" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { or };
|
return { or };
|
||||||
|
|
@ -138,9 +132,8 @@ export const Missions: CollectionConfig = {
|
||||||
type: "text",
|
type: "text",
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
'A "code name" provides a unique way to reference this mission beyond just the mission\'s name. For example, a mission called "Operation Dragon Break" could be codenamed "OP:DRGBRK".',
|
'A "code name" provides a unique way to reference this mission beyond just the mission\'s name. For example, a mission called "Operation Dragon Break" could be codenamed "OP:DRGBRK". Optional, but must be unique when set.',
|
||||||
},
|
},
|
||||||
required: true,
|
|
||||||
unique: true,
|
unique: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createCampaign } from "./create";
|
import { createCampaign, uploadUgcImage } from "./create";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
@ -11,6 +11,8 @@ import { Shield, Loader2 } from "lucide-react";
|
||||||
export function CreateCampaignDialog() {
|
export function CreateCampaignDialog() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [coverImage, setCoverImage] = useState<{ id: number; url: string } | null>(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
|
|
@ -21,16 +23,33 @@ export function CreateCampaignDialog() {
|
||||||
endDate: "",
|
endDate: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleImageUpload = async (file: File) => {
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const result = await uploadUgcImage(formData);
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
alert("Error uploading image: " + (result.error || "Unknown error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCoverImage({ id: result.data.id, url: result.data.url });
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
const result = await createCampaign(formData);
|
const result = await createCampaign({ ...formData, coverImageId: coverImage?.id ?? null });
|
||||||
|
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setCoverImage(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
name: "",
|
name: "",
|
||||||
summary: "An Arma 3 campaign.",
|
summary: "An Arma 3 campaign.",
|
||||||
|
|
@ -142,6 +161,33 @@ export function CreateCampaignDialog() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="coverImage" className="text-sm font-medium">
|
||||||
|
Cover Image <span className="text-xs text-muted-foreground">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="coverImage"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
disabled={isUploading}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) void handleImageUpload(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{coverImage && (
|
||||||
|
<img
|
||||||
|
src={coverImage.url}
|
||||||
|
alt="Campaign cover preview"
|
||||||
|
className="h-28 w-full rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Optional background image for the campaign card. 16:9 or square works best, 8 MB max.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
uploadSideOpModlist,
|
uploadSideOpModlist,
|
||||||
type FridaySideOpInput,
|
type FridaySideOpInput,
|
||||||
} from "@/app/(frontend)/intelligence/missions/actions";
|
} from "@/app/(frontend)/intelligence/missions/actions";
|
||||||
|
import { uploadUgcImage } from "./create";
|
||||||
import {
|
import {
|
||||||
SideOpNarrativeFields,
|
SideOpNarrativeFields,
|
||||||
type SideOpNarrative,
|
type SideOpNarrative,
|
||||||
|
|
@ -61,8 +62,10 @@ export interface SideOpDialogOption {
|
||||||
export interface SideOpDialogInitial {
|
export interface SideOpDialogInitial {
|
||||||
missionId: number;
|
missionId: number;
|
||||||
name: string;
|
name: string;
|
||||||
codeName: string;
|
codeName?: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
coverImageId?: number | null;
|
||||||
|
coverImageUrl?: string | null;
|
||||||
mapId: number | null;
|
mapId: number | null;
|
||||||
missionType: string;
|
missionType: string;
|
||||||
startDateTime: string;
|
startDateTime: string;
|
||||||
|
|
@ -138,6 +141,8 @@ export function CreateSideOpDialog({
|
||||||
const [newCampaignName, setNewCampaignName] = useState("");
|
const [newCampaignName, setNewCampaignName] = useState("");
|
||||||
const [narrative, setNarrative] = useState<SideOpNarrative>(defaultNarrative);
|
const [narrative, setNarrative] = useState<SideOpNarrative>(defaultNarrative);
|
||||||
const [narrativeOpen, setNarrativeOpen] = useState(false);
|
const [narrativeOpen, setNarrativeOpen] = useState(false);
|
||||||
|
const [coverImage, setCoverImage] = useState<{ id: number; url: string } | null>(null);
|
||||||
|
const [coverImageUploading, setCoverImageUploading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
|
@ -147,8 +152,13 @@ export function CreateSideOpDialog({
|
||||||
setNarrativeOpen(false);
|
setNarrativeOpen(false);
|
||||||
if (initial) {
|
if (initial) {
|
||||||
setName(initial.name);
|
setName(initial.name);
|
||||||
setCodeName(initial.codeName);
|
setCodeName(initial.codeName ?? "");
|
||||||
setSummary(initial.summary);
|
setSummary(initial.summary);
|
||||||
|
setCoverImage(
|
||||||
|
initial.coverImageId != null && initial.coverImageUrl
|
||||||
|
? { id: initial.coverImageId, url: initial.coverImageUrl }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
setMapId(initial.mapId ? String(initial.mapId) : "");
|
setMapId(initial.mapId ? String(initial.mapId) : "");
|
||||||
setMapChoice(initial.mapId ? String(initial.mapId) : "custom");
|
setMapChoice(initial.mapId ? String(initial.mapId) : "custom");
|
||||||
setCustomMapName("");
|
setCustomMapName("");
|
||||||
|
|
@ -168,6 +178,7 @@ export function CreateSideOpDialog({
|
||||||
assetsAvailable: initial.assetsAvailable ?? [],
|
assetsAvailable: initial.assetsAvailable ?? [],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
setCoverImage(null);
|
||||||
setNarrative(defaultNarrative());
|
setNarrative(defaultNarrative());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -234,10 +245,27 @@ export function CreateSideOpDialog({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCoverImageUpload(file: File) {
|
||||||
|
setCoverImageUploading(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const result = await uploadUgcImage(formData);
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
toast.error(result.error ?? "Failed to upload the image.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCoverImage({ id: result.data.id, url: result.data.url });
|
||||||
|
toast.success("Cover image uploaded.");
|
||||||
|
} finally {
|
||||||
|
setCoverImageUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!name.trim() || !codeName.trim() || !summary.trim()) {
|
if (!name.trim() || !summary.trim()) {
|
||||||
setError("Name, code name, and summary are required.");
|
setError("Name and summary are required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mapChoice === "custom" && !customMapName.trim()) {
|
if (mapChoice === "custom" && !customMapName.trim()) {
|
||||||
|
|
@ -261,8 +289,11 @@ export function CreateSideOpDialog({
|
||||||
const result = await updateFridaySideOp({
|
const result = await updateFridaySideOp({
|
||||||
missionId: initial.missionId,
|
missionId: initial.missionId,
|
||||||
name,
|
name,
|
||||||
codeName,
|
codeName: codeName.trim(),
|
||||||
summary,
|
summary,
|
||||||
|
...(coverImage && coverImage.id !== (initial.coverImageId ?? null)
|
||||||
|
? { coverImageId: coverImage.id }
|
||||||
|
: {}),
|
||||||
...mapInput(),
|
...mapInput(),
|
||||||
missionType: missionType as FridaySideOpInput["missionType"],
|
missionType: missionType as FridaySideOpInput["missionType"],
|
||||||
estimatedDuration: Number(estimatedDuration) || 240,
|
estimatedDuration: Number(estimatedDuration) || 240,
|
||||||
|
|
@ -289,8 +320,9 @@ export function CreateSideOpDialog({
|
||||||
|
|
||||||
const result = await createFridaySideOp({
|
const result = await createFridaySideOp({
|
||||||
name,
|
name,
|
||||||
codeName,
|
codeName: codeName.trim() || undefined,
|
||||||
summary,
|
summary,
|
||||||
|
...(coverImage ? { coverImageId: coverImage.id } : {}),
|
||||||
...mapInput(),
|
...mapInput(),
|
||||||
missionType: missionType as FridaySideOpInput["missionType"],
|
missionType: missionType as FridaySideOpInput["missionType"],
|
||||||
startDateTime: startDateIso,
|
startDateTime: startDateIso,
|
||||||
|
|
@ -335,7 +367,9 @@ export function CreateSideOpDialog({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="sideop-code">Code name</Label>
|
<Label htmlFor="sideop-code">
|
||||||
|
Code name <span className="text-xs text-muted-foreground">(optional)</span>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="sideop-code"
|
id="sideop-code"
|
||||||
value={codeName}
|
value={codeName}
|
||||||
|
|
@ -353,6 +387,31 @@ export function CreateSideOpDialog({
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="sideop-cover">Cover image</Label>
|
||||||
|
<Input
|
||||||
|
id="sideop-cover"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
disabled={coverImageUploading}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) void handleCoverImageUpload(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{coverImage && (
|
||||||
|
<img
|
||||||
|
src={coverImage.url}
|
||||||
|
alt="Mission cover preview"
|
||||||
|
className="h-28 w-full rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Optional background image for the mission page header. 16:9 or wider works best, 8 MB
|
||||||
|
max.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Map</Label>
|
<Label>Map</Label>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { parseDateKey } from "@/lib/intelligence/fridayTime";
|
||||||
export interface FridayCalendarMission {
|
export interface FridayCalendarMission {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
codeName: string;
|
codeName?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
startDateTime: string;
|
startDateTime: string;
|
||||||
operationType: string;
|
operationType: string;
|
||||||
|
|
@ -144,9 +144,13 @@ export function FridayCalendar({
|
||||||
{m.name}
|
{m.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
||||||
{m.codeName}
|
{[
|
||||||
{m.mapName ? ` · ${m.mapName}` : ""}
|
m.codeName,
|
||||||
{m.isOwn ? " · yours" : m.hostName ? ` · hosted by ${m.hostName}` : ""}
|
m.mapName,
|
||||||
|
m.isOwn ? "yours" : m.hostName ? `hosted by ${m.hostName}` : null,
|
||||||
|
]
|
||||||
|
.filter((part): part is string => Boolean(part))
|
||||||
|
.join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export interface MissionCardProps {
|
||||||
readonly mission: {
|
readonly mission: {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly codeName: string;
|
readonly codeName?: string | null;
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
readonly operationType: "main" | "side" | "external";
|
readonly operationType: "main" | "side" | "external";
|
||||||
readonly classification: {
|
readonly classification: {
|
||||||
|
|
@ -131,7 +131,9 @@ export function MissionCard({
|
||||||
<CrosshairIcon />
|
<CrosshairIcon />
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemTitle className="flex flex-col items-start text-start">
|
<ItemTitle className="flex flex-col items-start text-start">
|
||||||
|
{mission.codeName && (
|
||||||
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
|
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
|
||||||
|
)}
|
||||||
<span>{mission.name}</span>
|
<span>{mission.name}</span>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { CrosshairIcon } from "lucide-react";
|
||||||
|
|
||||||
interface MissionHeaderProps {
|
interface MissionHeaderProps {
|
||||||
name: string;
|
name: string;
|
||||||
codeName: string;
|
codeName?: string | null;
|
||||||
operationType: "main" | "side" | "external";
|
operationType: "main" | "side" | "external";
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
@ -26,12 +26,14 @@ const operationTypeColors: Record<string, string> = {
|
||||||
export function MissionHeader({ name, codeName, operationType, status }: MissionHeaderProps) {
|
export function MissionHeader({ name, codeName, operationType, status }: MissionHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
{codeName && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CrosshairIcon className="size-4 text-muted-foreground" />
|
<CrosshairIcon className="size-4 text-muted-foreground" />
|
||||||
<span className="font-mono text-xs text-muted-foreground uppercase tracking-wider">
|
<span className="font-mono text-xs text-muted-foreground uppercase tracking-wider">
|
||||||
{codeName}
|
{codeName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{name}</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{name}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ export function MissionReminderToasts() {
|
||||||
|
|
||||||
const toastId = `mission-reminder:${entry.key}`;
|
const toastId = `mission-reminder:${entry.key}`;
|
||||||
const leaderName = entry.leader.displayName || entry.leader.username;
|
const leaderName = entry.leader.displayName || entry.leader.username;
|
||||||
const missionName = entry.missionName || entry.missionCodeName;
|
const missionName = entry.missionName || entry.missionCodeName || "the mission";
|
||||||
shownKeysRef.current.add(entry.key);
|
shownKeysRef.current.add(entry.key);
|
||||||
activeToastRef.current = { key: entry.key, toastId };
|
activeToastRef.current = { key: entry.key, toastId };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface CreateCampaignData {
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
coverImageId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
|
|
@ -53,6 +54,10 @@ export async function createCampaign(data: CreateCampaignData) {
|
||||||
campaignData.description = data.description;
|
campaignData.description = data.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.coverImageId) {
|
||||||
|
campaignData.coverImage = data.coverImageId;
|
||||||
|
}
|
||||||
|
|
||||||
const campaign = await payload.create({
|
const campaign = await payload.create({
|
||||||
collection: "campaigns",
|
collection: "campaigns",
|
||||||
data: campaignData as any,
|
data: campaignData as any,
|
||||||
|
|
@ -70,3 +75,49 @@ export async function createCampaign(data: CreateCampaignData) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadUgcImage(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<{ success: boolean; error?: string; data?: { id: number; url: string; filename: string } }> {
|
||||||
|
try {
|
||||||
|
const { payload } = await authenticate();
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return { success: false, error: "No image file provided." };
|
||||||
|
}
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
return { success: false, error: "Only image files can be uploaded." };
|
||||||
|
}
|
||||||
|
if (file.size > 8 * 1024 * 1024) {
|
||||||
|
return { success: false, error: "Images must be 8 MB or smaller." };
|
||||||
|
}
|
||||||
|
const alt =
|
||||||
|
file.name
|
||||||
|
.replace(/\.[^/.]+$/, "")
|
||||||
|
.replace(/[^a-zA-Z0-9 _-]/g, "")
|
||||||
|
.trim() || "Image";
|
||||||
|
const doc = await payload.create({
|
||||||
|
collection: "media",
|
||||||
|
data: { alt },
|
||||||
|
file: {
|
||||||
|
data: Buffer.from(await file.arrayBuffer()),
|
||||||
|
mimetype: file.type,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
if (!doc.filename) {
|
||||||
|
return { success: false, error: "Upload failed: no file was stored." };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { id: doc.id, url: doc.url ?? `/api/media/file/${doc.filename}`, filename: doc.filename },
|
||||||
|
};
|
||||||
|
} 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export type ReminderLeader = {
|
||||||
export type MissionReminder = {
|
export type MissionReminder = {
|
||||||
readonly missionId: number;
|
readonly missionId: number;
|
||||||
readonly missionName: string;
|
readonly missionName: string;
|
||||||
readonly missionCodeName: string;
|
readonly missionCodeName: string | null;
|
||||||
readonly completedAt: string;
|
readonly completedAt: string;
|
||||||
readonly leaders: readonly ReminderLeader[];
|
readonly leaders: readonly ReminderLeader[];
|
||||||
};
|
};
|
||||||
|
|
@ -41,7 +41,7 @@ function isMissionReminder(value: unknown): value is MissionReminder {
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
typeof value.missionId === "number" &&
|
typeof value.missionId === "number" &&
|
||||||
typeof value.missionName === "string" &&
|
typeof value.missionName === "string" &&
|
||||||
typeof value.missionCodeName === "string" &&
|
(value.missionCodeName === null || typeof value.missionCodeName === "string") &&
|
||||||
typeof value.completedAt === "string" &&
|
typeof value.completedAt === "string" &&
|
||||||
Array.isArray(value.leaders) &&
|
Array.isArray(value.leaders) &&
|
||||||
value.leaders.every(isReminderLeader)
|
value.leaders.every(isReminderLeader)
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ export interface LeaderEvaluationAggregate {
|
||||||
export interface LeaderMissionAggregate {
|
export interface LeaderMissionAggregate {
|
||||||
missionId: number;
|
missionId: number;
|
||||||
missionName: string;
|
missionName: string;
|
||||||
missionCodeName: string;
|
missionCodeName: string | null;
|
||||||
startDateTime: string | null;
|
startDateTime: string | null;
|
||||||
totalRaters: number;
|
totalRaters: number;
|
||||||
percentage: number | null;
|
percentage: number | null;
|
||||||
|
|
@ -350,7 +350,7 @@ export async function getLeaderAggregate(
|
||||||
return {
|
return {
|
||||||
missionId,
|
missionId,
|
||||||
missionName: missionResult.name,
|
missionName: missionResult.name,
|
||||||
missionCodeName: missionResult.codeName,
|
missionCodeName: missionResult.codeName ?? null,
|
||||||
startDateTime: missionResult.classification?.startDateTime ?? null,
|
startDateTime: missionResult.classification?.startDateTime ?? null,
|
||||||
totalRaters: missionRaters,
|
totalRaters: missionRaters,
|
||||||
percentage:
|
percentage:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export interface ReminderLeader {
|
||||||
export interface MissionReminder {
|
export interface MissionReminder {
|
||||||
missionId: number;
|
missionId: number;
|
||||||
missionName: string;
|
missionName: string;
|
||||||
missionCodeName: string;
|
missionCodeName: string | null;
|
||||||
completedAt: string;
|
completedAt: string;
|
||||||
leaders: ReminderLeader[];
|
leaders: ReminderLeader[];
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +165,7 @@ export async function getMissionReminders(
|
||||||
return eligible.map(({ mission, completedAt, leaderIds }) => ({
|
return eligible.map(({ mission, completedAt, leaderIds }) => ({
|
||||||
missionId: mission.id,
|
missionId: mission.id,
|
||||||
missionName: mission.name,
|
missionName: mission.name,
|
||||||
missionCodeName: mission.codeName,
|
missionCodeName: mission.codeName ?? null,
|
||||||
completedAt,
|
completedAt,
|
||||||
leaders: leaderIds
|
leaders: leaderIds
|
||||||
.map((id) => leaderById.get(id))
|
.map((id) => leaderById.get(id))
|
||||||
|
|
|
||||||
24488
src/migrations/20260910_221018_make_missions_codename_optional.json
Normal file
24488
src/migrations/20260910_221018_make_missions_codename_optional.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "missions" ALTER COLUMN "code_name" DROP NOT NULL;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "missions" ALTER COLUMN "code_name" SET NOT NULL;`)
|
||||||
|
}
|
||||||
|
|
@ -99,8 +99,10 @@ export const script = async (config: SanitizedConfig) => {
|
||||||
// The source may itself be a previously generated clone whose codeName
|
// The source may itself be a previously generated clone whose codeName
|
||||||
// already ends in a datestamp — strip it so reruns derive a stable
|
// already ends in a datestamp — strip it so reruns derive a stable
|
||||||
// codeName (same target Saturday → same codeName → idempotency check
|
// codeName (same target Saturday → same codeName → idempotency check
|
||||||
// fires) instead of stacking a new suffix on every run.
|
// fires) instead of stacking a new suffix on every run. Codenames are
|
||||||
const baseCodeName = source.codeName.replace(/-\d{8}$/, "");
|
// optional now, so the base falls back to the mission name.
|
||||||
|
const baseCodeName =
|
||||||
|
(source.codeName?.replace(/-\d{8}$/, "") || source.name).trim() || "operation";
|
||||||
const codeName = `${baseCodeName}-${dateStamp}`;
|
const codeName = `${baseCodeName}-${dateStamp}`;
|
||||||
|
|
||||||
const existing = await payload.find({
|
const existing = await payload.find({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue