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 { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
|
||||
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 { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
||||
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
||||
|
|
@ -138,6 +140,28 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
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([
|
||||
getMissionAttendance(payload, mission.id),
|
||||
currentUser
|
||||
|
|
@ -289,7 +313,12 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
|
||||
<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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
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 { 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 {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
missionCritical?: boolean | null;
|
||||
successOrFailConditions?: string | null;
|
||||
redacted?: boolean | null;
|
||||
}
|
||||
|
||||
interface MissionObjectivesProps {
|
||||
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) {
|
||||
return (
|
||||
<Item variant="outline">
|
||||
|
|
@ -37,7 +98,7 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
|
|||
Objectives
|
||||
</h2>
|
||||
</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">
|
||||
<thead>
|
||||
<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">
|
||||
Success / Fail Conditions
|
||||
</th>
|
||||
{isEditable && (
|
||||
<th className="text-center px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{objectives.map((obj, index) => (
|
||||
<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">
|
||||
{obj.description ?? "—"}
|
||||
<ObjectiveText
|
||||
text={obj.description}
|
||||
redacted={!!obj.redacted}
|
||||
canReveal={canReveal}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{obj.missionCritical ? (
|
||||
|
|
@ -70,13 +146,62 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
|
|||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{obj.successOrFailConditions ?? "—"}
|
||||
<ObjectiveText
|
||||
text={obj.successOrFailConditions}
|
||||
redacted={!!obj.redacted}
|
||||
canReveal={canReveal}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{toggleError && <p className="text-sm text-destructive">{toggleError}</p>}
|
||||
</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;
|
||||
successOrFailConditions?: string | null;
|
||||
/**
|
||||
* If true, this objective will be hidden from players until revealed.
|
||||
*/
|
||||
redacted?: boolean | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
|
|
@ -3576,6 +3580,7 @@ export interface MissionsSelect<T extends boolean = true> {
|
|||
description?: T;
|
||||
missionCritical?: T;
|
||||
successOrFailConditions?: T;
|
||||
redacted?: T;
|
||||
id?: T;
|
||||
};
|
||||
civilianPresence?: T;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@ export const EventTypes = {
|
|||
// Attendance
|
||||
MissionAttendanceChange: "mission:attendance-change",
|
||||
|
||||
// Missions
|
||||
MissionAutoComplete: "mission:auto-complete",
|
||||
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
||||
|
||||
// Helpdesk
|
||||
TicketCreate: "ticket:create",
|
||||
TicketReply: "ticket:reply",
|
||||
|
|
|
|||
Loading…
Reference in a new issue