feat(missions): add redacted objective toggle
Mission objectives gain a redacted checkbox. Redacted objectives are shown block-redacted to players; mission managers, authors, and Zeus see the real text (via tooltip) and can toggle the flag from the mission detail page. Toggles emit mission:objective-redacted-toggle events. Also registers the mission:auto-complete event type used by the upcoming mission-tick bin.
This commit is contained in:
parent
c0d00fc113
commit
824b03ec49
7 changed files with 280 additions and 7 deletions
|
|
@ -17,6 +17,8 @@ import {
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
|
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
|
||||||
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
|
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import { redactText } from "@/lib/redaction";
|
||||||
import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
|
import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
|
||||||
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
||||||
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
||||||
|
|
@ -138,6 +140,28 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
canSetHeaders: false,
|
canSetHeaders: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canManage = currentUser
|
||||||
|
? await hasPermission(payload, currentUser, "missions:update")
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const authorOrZeusIds = [
|
||||||
|
...(mission.ownershipAndStatus.authors ?? []),
|
||||||
|
...(mission.ownershipAndStatus.zeus ?? []),
|
||||||
|
].map((u) => (typeof u === "object" ? u.id : u));
|
||||||
|
const canReveal =
|
||||||
|
canManage || (currentUser ? authorOrZeusIds.includes(currentUser.id as number) : false);
|
||||||
|
|
||||||
|
const objectives = (mission.missionObjectives ?? []).map((obj) =>
|
||||||
|
obj.redacted && !canReveal
|
||||||
|
? {
|
||||||
|
...obj,
|
||||||
|
title: redactText(obj.title) ?? obj.title,
|
||||||
|
description: redactText(obj.description),
|
||||||
|
successOrFailConditions: redactText(obj.successOrFailConditions),
|
||||||
|
}
|
||||||
|
: obj,
|
||||||
|
);
|
||||||
|
|
||||||
const [attendance, userAttendance] = await Promise.all([
|
const [attendance, userAttendance] = await Promise.all([
|
||||||
getMissionAttendance(payload, mission.id),
|
getMissionAttendance(payload, mission.id),
|
||||||
currentUser
|
currentUser
|
||||||
|
|
@ -289,7 +313,12 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
|
|
||||||
<MissionBriefingSection briefing={mission.briefing ?? null} />
|
<MissionBriefingSection briefing={mission.briefing ?? null} />
|
||||||
|
|
||||||
<MissionObjectives objectives={mission.missionObjectives ?? null} />
|
<MissionObjectives
|
||||||
|
objectives={objectives}
|
||||||
|
missionId={mission.id}
|
||||||
|
isEditable={canManage}
|
||||||
|
canReveal={canReveal}
|
||||||
|
/>
|
||||||
|
|
||||||
<MissionFactions factionsInvolved={mission.factionsInvolved ?? null} />
|
<MissionFactions factionsInvolved={mission.factionsInvolved ?? null} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
|
export interface ActionResult<T = undefined> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleObjectiveRedacted(input: {
|
||||||
|
missionId: number;
|
||||||
|
objectiveIndex: number;
|
||||||
|
redacted: boolean;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
// Check permissions - only admins/developers can toggle redacted flags
|
||||||
|
const hasAccess = await hasPermission(payload, user, "missions:update");
|
||||||
|
if (!hasAccess) {
|
||||||
|
return { success: false, error: "You do not have permission to modify mission objectives." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current mission data
|
||||||
|
const mission = await payload.findByID({
|
||||||
|
collection: "missions",
|
||||||
|
id: input.missionId,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return { success: false, error: "Mission not found." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure objectives array exists and has the requested index
|
||||||
|
const objectives = mission.missionObjectives || [];
|
||||||
|
if (input.objectiveIndex < 0 || input.objectiveIndex >= objectives.length) {
|
||||||
|
return { success: false, error: "Invalid objective index." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the redacted flag for the specific objective
|
||||||
|
const updatedObjectives = [...objectives];
|
||||||
|
updatedObjectives[input.objectiveIndex] = {
|
||||||
|
...updatedObjectives[input.objectiveIndex],
|
||||||
|
redacted: input.redacted,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the mission with the new objectives array
|
||||||
|
await payload.update({
|
||||||
|
collection: "missions",
|
||||||
|
id: input.missionId,
|
||||||
|
data: {
|
||||||
|
missionObjectives: updatedObjectives,
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit an event for the objective update
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: "mission:objective-redacted-toggle",
|
||||||
|
message: `Objective redacted flag toggled for mission ${mission.name}`,
|
||||||
|
actor: user.id,
|
||||||
|
targetCollection: "missions",
|
||||||
|
targetId: input.missionId,
|
||||||
|
data: {
|
||||||
|
objectiveIndex: input.objectiveIndex,
|
||||||
|
redacted: input.redacted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -437,6 +437,16 @@ export const Missions: CollectionConfig = {
|
||||||
type: "textarea",
|
type: "textarea",
|
||||||
label: "Success / Fail Conditions",
|
label: "Success / Fail Conditions",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "redacted",
|
||||||
|
type: "checkbox",
|
||||||
|
label: "Redacted",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"If true, this objective will be hidden from players until revealed.",
|
||||||
|
},
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,79 @@
|
||||||
import { CheckIcon, CrosshairIcon, XIcon } from "lucide-react";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CheckIcon, CrosshairIcon, EyeIcon, EyeOffIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { toggleObjectiveRedacted } from "@/app/(frontend)/intelligence/missions/objectives/actions";
|
||||||
|
import { redactText } from "@/lib/redaction";
|
||||||
|
|
||||||
interface Objective {
|
interface Objective {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
missionCritical?: boolean | null;
|
missionCritical?: boolean | null;
|
||||||
successOrFailConditions?: string | null;
|
successOrFailConditions?: string | null;
|
||||||
|
redacted?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MissionObjectivesProps {
|
interface MissionObjectivesProps {
|
||||||
objectives: Objective[] | null;
|
objectives: Objective[] | null;
|
||||||
|
missionId?: number;
|
||||||
|
isEditable?: boolean;
|
||||||
|
canReveal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MissionObjectives({ objectives }: MissionObjectivesProps) {
|
function ObjectiveText({
|
||||||
|
text,
|
||||||
|
redacted,
|
||||||
|
canReveal,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string | null | undefined;
|
||||||
|
redacted: boolean;
|
||||||
|
canReveal: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (!redacted) {
|
||||||
|
return <span className={className}>{text ?? "—"}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const display = redactText(text) ?? "—";
|
||||||
|
|
||||||
|
if (!canReveal) {
|
||||||
|
return <span className={`${className ?? ""} select-none break-all`}>{display}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={150}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className={`${className ?? ""} cursor-help select-none break-all`}>{display}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
|
||||||
|
{text ?? "—"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MissionObjectives({
|
||||||
|
objectives,
|
||||||
|
missionId,
|
||||||
|
isEditable = false,
|
||||||
|
canReveal = false,
|
||||||
|
}: MissionObjectivesProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [togglingIndex, setTogglingIndex] = useState<number | null>(null);
|
||||||
|
const [toggleError, setToggleError] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!objectives || objectives.length === 0) {
|
if (!objectives || objectives.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Item variant="outline">
|
<Item variant="outline">
|
||||||
|
|
@ -37,7 +98,7 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
|
||||||
Objectives
|
Objectives
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-border rounded-lg overflow-hidden">
|
<div className="border border-border rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-muted/50 border-b border-border">
|
<tr className="bg-muted/50 border-b border-border">
|
||||||
|
|
@ -53,14 +114,29 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
|
||||||
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
||||||
Success / Fail Conditions
|
Success / Fail Conditions
|
||||||
</th>
|
</th>
|
||||||
|
{isEditable && (
|
||||||
|
<th className="text-center px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{objectives.map((obj, index) => (
|
{objectives.map((obj, index) => (
|
||||||
<tr key={index} className="border-b border-border/50">
|
<tr key={index} className="border-b border-border/50">
|
||||||
<td className="px-4 py-2 font-medium">{obj.title}</td>
|
<td className="px-4 py-2 font-medium">
|
||||||
|
<ObjectiveText
|
||||||
|
text={obj.title}
|
||||||
|
redacted={!!obj.redacted}
|
||||||
|
canReveal={canReveal}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2 text-muted-foreground max-w-md wrap-break-word">
|
<td className="px-4 py-2 text-muted-foreground max-w-md wrap-break-word">
|
||||||
{obj.description ?? "—"}
|
<ObjectiveText
|
||||||
|
text={obj.description}
|
||||||
|
redacted={!!obj.redacted}
|
||||||
|
canReveal={canReveal}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{obj.missionCritical ? (
|
{obj.missionCritical ? (
|
||||||
|
|
@ -70,13 +146,62 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-muted-foreground">
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
{obj.successOrFailConditions ?? "—"}
|
<ObjectiveText
|
||||||
|
text={obj.successOrFailConditions}
|
||||||
|
redacted={!!obj.redacted}
|
||||||
|
canReveal={canReveal}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
{isEditable && (
|
||||||
|
<td className="px-4 py-2 text-center whitespace-nowrap">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={togglingIndex !== null}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!missionId || togglingIndex !== null) return;
|
||||||
|
setToggleError(null);
|
||||||
|
setTogglingIndex(index);
|
||||||
|
try {
|
||||||
|
const res = await toggleObjectiveRedacted({
|
||||||
|
missionId,
|
||||||
|
objectiveIndex: index,
|
||||||
|
redacted: !obj.redacted,
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
setToggleError(res.error ?? "Failed to update objective.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setToggleError("Something went wrong. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setTogglingIndex(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{togglingIndex === index ? (
|
||||||
|
<Loader2Icon className="size-3.5 mr-1 animate-spin" />
|
||||||
|
) : obj.redacted ? (
|
||||||
|
<>
|
||||||
|
<EyeIcon className="size-3.5 mr-1" />
|
||||||
|
Show
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EyeOffIcon className="size-3.5 mr-1" />
|
||||||
|
Hide
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{toggleError && <p className="text-sm text-destructive">{toggleError}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
src/lib/redaction.ts
Normal file
6
src/lib/redaction.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
const BLOCK = "█";
|
||||||
|
|
||||||
|
export function redactText(text: string | null | undefined): string | null {
|
||||||
|
if (!text) return null;
|
||||||
|
return text.replace(/\S/g, BLOCK);
|
||||||
|
}
|
||||||
|
|
@ -1924,6 +1924,10 @@ export interface Mission {
|
||||||
*/
|
*/
|
||||||
missionCritical?: boolean | null;
|
missionCritical?: boolean | null;
|
||||||
successOrFailConditions?: string | null;
|
successOrFailConditions?: string | null;
|
||||||
|
/**
|
||||||
|
* If true, this objective will be hidden from players until revealed.
|
||||||
|
*/
|
||||||
|
redacted?: boolean | null;
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
|
|
@ -3576,6 +3580,7 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
description?: T;
|
description?: T;
|
||||||
missionCritical?: T;
|
missionCritical?: T;
|
||||||
successOrFailConditions?: T;
|
successOrFailConditions?: T;
|
||||||
|
redacted?: T;
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
civilianPresence?: T;
|
civilianPresence?: T;
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ export const EventTypes = {
|
||||||
// Attendance
|
// Attendance
|
||||||
MissionAttendanceChange: "mission:attendance-change",
|
MissionAttendanceChange: "mission:attendance-change",
|
||||||
|
|
||||||
|
// Missions
|
||||||
|
MissionAutoComplete: "mission:auto-complete",
|
||||||
|
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
||||||
|
|
||||||
// Helpdesk
|
// Helpdesk
|
||||||
TicketCreate: "ticket:create",
|
TicketCreate: "ticket:create",
|
||||||
TicketReply: "ticket:reply",
|
TicketReply: "ticket:reply",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue