Compare commits
6 commits
2ed90d9f09
...
cb4aa0bf7e
| Author | SHA1 | Date | |
|---|---|---|---|
| cb4aa0bf7e | |||
| 350fdb7ac7 | |||
| 09ed08caf3 | |||
| f0af778713 | |||
| 365e8561ec | |||
| 7180b0aa4c |
38 changed files with 51949 additions and 183 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -66,5 +66,6 @@ node_modules/
|
||||||
/.logs/
|
/.logs/
|
||||||
/.omo/
|
/.omo/
|
||||||
/.opencode/
|
/.opencode/
|
||||||
|
/.openchamber/
|
||||||
/.playwright-mcp/
|
/.playwright-mcp/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.29",
|
"version": "0.2.30",
|
||||||
"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",
|
||||||
|
|
|
||||||
82
src/app/(frontend)/actions.ts
Normal file
82
src/app/(frontend)/actions.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { normalizeDashboardLayout } from "@/lib/dashboard/layout";
|
||||||
|
|
||||||
|
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 (!isPayloadUser(user)) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDashboardLayout(
|
||||||
|
layout: unknown,
|
||||||
|
): Promise<ActionResult<boolean>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
const userId = user.id as number;
|
||||||
|
|
||||||
|
// Write the full preferences object (same approach as updatePreferences in
|
||||||
|
// account/actions.ts) so sibling preference groups can never be clobbered
|
||||||
|
// by a partial nested update. The layout itself is sanitized server-side;
|
||||||
|
// unknown ids are dropped and missing widgets fall back to defaults.
|
||||||
|
const normalized = normalizeDashboardLayout(layout);
|
||||||
|
const fullUser = await payload.findByID({
|
||||||
|
collection: "users",
|
||||||
|
id: userId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const preferences = fullUser.preferences ?? {};
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "users",
|
||||||
|
id: userId,
|
||||||
|
data: {
|
||||||
|
preferences: {
|
||||||
|
notifications: {
|
||||||
|
mutedAll: preferences.notifications?.mutedAll ?? false,
|
||||||
|
mutedTypes: preferences.notifications?.mutedTypes ?? [],
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
showCallsign: preferences.display?.showCallsign ?? false,
|
||||||
|
showVersionOverlay: preferences.display?.showVersionOverlay ?? true,
|
||||||
|
showBackgroundTexture: preferences.display?.showBackgroundTexture ?? true,
|
||||||
|
leadOrActual: preferences.display?.leadOrActual ?? "lead",
|
||||||
|
xpDisplayMini: preferences.display?.xpDisplayMini ?? false,
|
||||||
|
},
|
||||||
|
discord: {
|
||||||
|
enabled: preferences.discord?.enabled ?? false,
|
||||||
|
mutedTypes: preferences.discord?.mutedTypes ?? [],
|
||||||
|
},
|
||||||
|
dashboard: { layout: normalized as unknown as Record<string, unknown> },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import { ShieldIcon } from "lucide-react";
|
import { ShieldIcon } from "lucide-react";
|
||||||
import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard";
|
import { CampaignsList } from "@/components/frontend/intelligence/CampaignsList";
|
||||||
import { CreateCampaignDialog } from "@/components/frontend/intelligence/CreateCampaignDialog";
|
import { CreateCampaignDialog } from "@/components/frontend/intelligence/CreateCampaignDialog";
|
||||||
import type { Media } from "@/payload-types";
|
import type { Media } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -100,13 +100,6 @@ export default async function CampaignsPage() {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const officialCampaigns = campaignsWithStats.filter(
|
|
||||||
(campaign) => campaign.campaignMode === "official",
|
|
||||||
);
|
|
||||||
const communityCampaigns = campaignsWithStats.filter(
|
|
||||||
(campaign) => campaign.campaignMode !== "official",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-8">
|
<div className="p-5 flex flex-col gap-8">
|
||||||
<div className="flex flex-row justify-between gap-2">
|
<div className="flex flex-row justify-between gap-2">
|
||||||
|
|
@ -125,51 +118,7 @@ export default async function CampaignsPage() {
|
||||||
<CreateCampaignDialog />
|
<CreateCampaignDialog />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{campaignsWithStats.length > 0 ? (
|
<CampaignsList campaigns={campaignsWithStats} />
|
||||||
<>
|
|
||||||
{officialCampaigns.length > 0 && (
|
|
||||||
<section className="flex flex-col items-center gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h2 className="text-xl font-semibold uppercase tracking-wider text-white">
|
|
||||||
Official Campaigns
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Command-sanctioned operations at the heart of the unit's story.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 w-full max-w-6xl mx-auto">
|
|
||||||
{officialCampaigns.map((campaign) => (
|
|
||||||
<CampaignCard key={campaign.id} campaign={campaign} size="lg" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{officialCampaigns.length > 0 && communityCampaigns.length > 0 && (
|
|
||||||
<hr className="w-48 mx-auto border-border/60" />
|
|
||||||
)}
|
|
||||||
{communityCampaigns.length > 0 && (
|
|
||||||
<section className="flex flex-col items-center gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
Community Campaigns
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Player-made side operations and stories, open to everyone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full max-w-6xl mx-auto">
|
|
||||||
{communityCampaigns.map((campaign) => (
|
|
||||||
<CampaignCard key={campaign.id} campaign={campaign} size="sm" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
||||||
<p className="text-sm">No campaigns found.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,40 @@
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import type { Assignment, Campaign, GameStructure, Map, Media, Rank, User } from "@/payload-types";
|
import type { Assignment, Campaign, GameStructure, Map, Media, Rank, User } from "@/payload-types";
|
||||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||||
|
import { getCurrencyConfig } from "@/lib/banking";
|
||||||
|
import { CLOSING_STATUSES } from "@/lib/tickets/ticketMeta";
|
||||||
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";
|
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";
|
||||||
import { MissionBriefing } from "@/components/frontend/dashboard/MissionBriefing";
|
import { MissionBriefing } from "@/components/frontend/dashboard/MissionBriefing";
|
||||||
import { RecentEvents } from "@/components/frontend/dashboard/RecentEvents";
|
import { RecentEvents } from "@/components/frontend/dashboard/RecentEvents";
|
||||||
import { QuickStats } from "@/components/frontend/dashboard/QuickStats";
|
import { QuickStats } from "@/components/frontend/dashboard/QuickStats";
|
||||||
import { UpcomingMissions } from "@/components/frontend/dashboard/UpcomingMissions";
|
import { UpcomingMissions } from "@/components/frontend/dashboard/UpcomingMissions";
|
||||||
import { ActiveCampaigns } from "@/components/frontend/dashboard/ActiveCampaigns";
|
import { ActiveCampaigns } from "@/components/frontend/dashboard/ActiveCampaigns";
|
||||||
|
import { WalletWidget } from "@/components/frontend/dashboard/WalletWidget";
|
||||||
|
import { OpenNegotiations } from "@/components/frontend/dashboard/OpenNegotiations";
|
||||||
|
import { MyTickets } from "@/components/frontend/dashboard/MyTickets";
|
||||||
|
import { RecentNotifications } from "@/components/frontend/dashboard/RecentNotifications";
|
||||||
|
import {
|
||||||
|
DashboardGrid,
|
||||||
|
type DashboardGridWidget,
|
||||||
|
} from "@/components/frontend/dashboard/DashboardGrid";
|
||||||
|
import {
|
||||||
|
normalizeDashboardLayout,
|
||||||
|
widgetSpan,
|
||||||
|
type DashboardLayout,
|
||||||
|
type DashboardWidgetId,
|
||||||
|
} from "@/lib/dashboard/layout";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
const headers = await nextHeaders();
|
const headers = await nextHeaders();
|
||||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user || !isPayloadUser(user)) return null;
|
||||||
|
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
|
|
||||||
|
|
@ -207,6 +225,169 @@ export default async function HomePage() {
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Personal widgets: wallet, open negotiations, tickets, notifications.
|
||||||
|
// Negotiations are fetched in a second step so seller-side threads can be
|
||||||
|
// matched against the user's own active listing ids (same filter approach as
|
||||||
|
// the market page, which avoids deep `listing.seller` relationship queries).
|
||||||
|
const [walletRes, activeListingsRes, ticketsRes, notificationsRes, currencyConfig] =
|
||||||
|
await Promise.all([
|
||||||
|
payload
|
||||||
|
.find({
|
||||||
|
collection: "bank-accounts",
|
||||||
|
where: {
|
||||||
|
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: userId } }],
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
.catch(() => null),
|
||||||
|
payload
|
||||||
|
.find({
|
||||||
|
collection: "market-listings",
|
||||||
|
where: {
|
||||||
|
and: [{ status: { equals: "active" } }, { seller: { equals: userId } }],
|
||||||
|
},
|
||||||
|
limit: 100,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
.catch(() => null),
|
||||||
|
payload
|
||||||
|
.find({
|
||||||
|
collection: "tickets",
|
||||||
|
where: { reporter: { equals: userId } },
|
||||||
|
sort: "-updatedAt",
|
||||||
|
limit: 10,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
.catch(() => ({ docs: [] })),
|
||||||
|
payload
|
||||||
|
.find({
|
||||||
|
collection: "user-notifications",
|
||||||
|
where: { user: { equals: userId } },
|
||||||
|
sort: "-createdAt",
|
||||||
|
limit: 8,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
.catch(() => ({ docs: [] })),
|
||||||
|
getCurrencyConfig(payload),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const walletAccount = walletRes?.docs[0] ?? null;
|
||||||
|
const myActiveListingIds = (activeListingsRes?.docs ?? []).map((listing) => listing.id);
|
||||||
|
|
||||||
|
const negotiationsRes = await payload
|
||||||
|
.find({
|
||||||
|
collection: "market-negotiations",
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ status: { equals: "open" } },
|
||||||
|
{
|
||||||
|
or: [{ buyer: { equals: userId } }, { listing: { in: myActiveListingIds } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sort: "-updatedAt",
|
||||||
|
limit: 6,
|
||||||
|
depth: 2,
|
||||||
|
})
|
||||||
|
.catch(() => ({ docs: [] }));
|
||||||
|
const negotiations = negotiationsRes.docs.slice(0, 5);
|
||||||
|
|
||||||
|
const openTickets = ticketsRes.docs.filter((t) => !CLOSING_STATUSES.includes(t.status));
|
||||||
|
const recentTickets = [
|
||||||
|
...openTickets,
|
||||||
|
...ticketsRes.docs.filter((t) => CLOSING_STATUSES.includes(t.status)),
|
||||||
|
].slice(0, 5);
|
||||||
|
|
||||||
|
const unreadNotifications = notificationsRes.docs.filter((n) => !n.read);
|
||||||
|
const recentNotifications = [
|
||||||
|
...unreadNotifications,
|
||||||
|
...notificationsRes.docs.filter((n) => n.read),
|
||||||
|
].slice(0, 5);
|
||||||
|
|
||||||
|
// Build the dashboard widgets for the customizable grid. Widgets whose data
|
||||||
|
// is empty (negotiations, notifications) or that the viewer cannot see
|
||||||
|
// (intel-gated) are omitted so they never render as empty drag targets.
|
||||||
|
const widgetNodes = new Map<DashboardWidgetId, ReactNode>();
|
||||||
|
if (profile && profileUser) {
|
||||||
|
widgetNodes.set(
|
||||||
|
"profile-summary",
|
||||||
|
<ProfileSummary
|
||||||
|
displayName={profileUser.displayName}
|
||||||
|
username={profileUser.username}
|
||||||
|
rank={rankData}
|
||||||
|
xpLevel={xpLevel}
|
||||||
|
assignments={assignments}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
widgetNodes.set(
|
||||||
|
"mission-briefing",
|
||||||
|
<MissionBriefing
|
||||||
|
mission={mission}
|
||||||
|
heading={upcomingRes.docs[0] ? "Next Mission" : "Last Mission"}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
widgetNodes.set("recent-events", <RecentEvents events={events} currentUserId={userId} />);
|
||||||
|
widgetNodes.set(
|
||||||
|
"quick-stats",
|
||||||
|
<QuickStats
|
||||||
|
activeCampaigns={campaignsRes.totalDocs}
|
||||||
|
totalMissions={missionsCountRes.totalDocs}
|
||||||
|
totalStructures={structuresRes.totalDocs}
|
||||||
|
totalFactions={factionsRes.totalDocs}
|
||||||
|
totalMembers={profilesRes.totalDocs}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
widgetNodes.set(
|
||||||
|
"wallet",
|
||||||
|
<WalletWidget account={walletAccount} currencyConfig={currencyConfig} />,
|
||||||
|
);
|
||||||
|
if (negotiations.length > 0) {
|
||||||
|
widgetNodes.set(
|
||||||
|
"negotiations",
|
||||||
|
<OpenNegotiations
|
||||||
|
negotiations={negotiations}
|
||||||
|
currentUserId={userId}
|
||||||
|
currencyConfig={currencyConfig}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
widgetNodes.set(
|
||||||
|
"my-tickets",
|
||||||
|
<MyTickets tickets={recentTickets} openCount={openTickets.length} />,
|
||||||
|
);
|
||||||
|
if (recentNotifications.length > 0) {
|
||||||
|
widgetNodes.set(
|
||||||
|
"notifications",
|
||||||
|
<RecentNotifications
|
||||||
|
notifications={recentNotifications}
|
||||||
|
unreadCount={unreadNotifications.length}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hasIntelligence) {
|
||||||
|
widgetNodes.set(
|
||||||
|
"upcoming-missions",
|
||||||
|
<UpcomingMissions missions={upcomingMissions} />,
|
||||||
|
);
|
||||||
|
widgetNodes.set("active-campaigns", <ActiveCampaigns campaigns={activeCampaigns} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedLayout = normalizeDashboardLayout(user.preferences?.dashboard?.layout);
|
||||||
|
const availableIds = new Set(widgetNodes.keys());
|
||||||
|
const dashboardLayout: DashboardLayout = {
|
||||||
|
main: savedLayout.main.filter((id) => availableIds.has(id)),
|
||||||
|
sidebar: savedLayout.sidebar.filter((id) => availableIds.has(id)),
|
||||||
|
spans: savedLayout.spans,
|
||||||
|
};
|
||||||
|
const widgets = dashboardLayout.main
|
||||||
|
.concat(dashboardLayout.sidebar)
|
||||||
|
.flatMap<DashboardGridWidget>((id) => {
|
||||||
|
const node = widgetNodes.get(id);
|
||||||
|
return node ? [{ id, span: widgetSpan(id), node }] : [];
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<div className="p-5 flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
@ -216,39 +397,7 @@ export default async function HomePage() {
|
||||||
<p className="text-sm text-muted-foreground">Here's your operations dashboard.</p>
|
<p className="text-sm text-muted-foreground">Here's your operations dashboard.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<DashboardGrid layout={dashboardLayout} widgets={widgets} />
|
||||||
{/* ── Main Content (2/3) ──────────────────────────── */}
|
|
||||||
<div className="xl:col-span-2 flex flex-col gap-6">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{profile && profileUser && (
|
|
||||||
<ProfileSummary
|
|
||||||
displayName={profileUser.displayName}
|
|
||||||
username={profileUser.username}
|
|
||||||
rank={rankData}
|
|
||||||
xpLevel={xpLevel}
|
|
||||||
assignments={assignments}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MissionBriefing mission={mission} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecentEvents events={events} currentUserId={userId} />
|
|
||||||
|
|
||||||
<QuickStats
|
|
||||||
activeCampaigns={campaignsRes.totalDocs}
|
|
||||||
totalMissions={missionsCountRes.totalDocs}
|
|
||||||
totalStructures={structuresRes.totalDocs}
|
|
||||||
totalFactions={factionsRes.totalDocs}
|
|
||||||
totalMembers={profilesRes.totalDocs}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{hasIntelligence && <UpcomingMissions missions={upcomingMissions} />}
|
|
||||||
{hasIntelligence && <ActiveCampaigns campaigns={activeCampaigns} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,18 @@ export const Users: CollectionConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "dashboard",
|
||||||
|
label: "Dashboard",
|
||||||
|
type: "group",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "layout",
|
||||||
|
label: "Dashboard layout (widget ids per zone, managed by the dashboard)",
|
||||||
|
type: "json",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
454
src/components/frontend/dashboard/DashboardGrid.tsx
Normal file
454
src/components/frontend/dashboard/DashboardGrid.tsx
Normal file
|
|
@ -0,0 +1,454 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
closestCorners,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
rectSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
GripVerticalIcon,
|
||||||
|
LayoutGridIcon,
|
||||||
|
Maximize2Icon,
|
||||||
|
Minimize2Icon,
|
||||||
|
RotateCcwIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { saveDashboardLayout } from "@/app/(frontend)/actions";
|
||||||
|
import {
|
||||||
|
DEFAULT_DASHBOARD_LAYOUT,
|
||||||
|
WIDGET_LABELS,
|
||||||
|
effectiveSpan,
|
||||||
|
type DashboardLayout,
|
||||||
|
type DashboardWidgetId,
|
||||||
|
type DashboardZone,
|
||||||
|
} from "@/lib/dashboard/layout";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface DashboardGridWidget {
|
||||||
|
readonly id: DashboardWidgetId;
|
||||||
|
readonly node: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardGridProps {
|
||||||
|
readonly layout: DashboardLayout;
|
||||||
|
readonly widgets: readonly DashboardGridWidget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZONE_MAIN = "zone:main";
|
||||||
|
const ZONE_SIDEBAR = "zone:sidebar";
|
||||||
|
|
||||||
|
function zoneFromContainerId(id: string): DashboardZone | null {
|
||||||
|
if (id === ZONE_MAIN) return "main";
|
||||||
|
if (id === ZONE_SIDEBAR) return "sidebar";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findZone(layout: DashboardLayout, id: string): DashboardZone | null {
|
||||||
|
if (layout.main.includes(id as DashboardWidgetId)) return "main";
|
||||||
|
if (layout.sidebar.includes(id as DashboardWidgetId)) return "sidebar";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the next layout for a drop, or null when the drop changes nothing.
|
||||||
|
* Pure and driven only by the layout as of drag end, so there is no state
|
||||||
|
* mutation during the drag itself (mutating layout in onDragOver re-renders
|
||||||
|
* the grid and re-measures droppables mid-drag, which ping-pongs collision
|
||||||
|
* detection and crashes React with "Maximum update depth exceeded").
|
||||||
|
*/
|
||||||
|
function placementFor(
|
||||||
|
layout: DashboardLayout,
|
||||||
|
activeId: string,
|
||||||
|
overId: string,
|
||||||
|
): DashboardLayout | null {
|
||||||
|
const from = findZone(layout, activeId);
|
||||||
|
if (!from) return null;
|
||||||
|
const toContainer = zoneFromContainerId(overId);
|
||||||
|
const to = toContainer ?? findZone(layout, overId);
|
||||||
|
if (!to) return null;
|
||||||
|
|
||||||
|
if (to === from) {
|
||||||
|
if (toContainer) return null;
|
||||||
|
const items = layout[from];
|
||||||
|
const fromIndex = items.indexOf(activeId as DashboardWidgetId);
|
||||||
|
const toIndex = items.indexOf(overId as DashboardWidgetId);
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
|
||||||
|
return { ...layout, [from]: arrayMove(items, fromIndex, toIndex) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = layout[from].filter((id) => id !== (activeId as DashboardWidgetId));
|
||||||
|
const target = [...layout[to]];
|
||||||
|
if (toContainer) {
|
||||||
|
target.push(activeId as DashboardWidgetId);
|
||||||
|
} else {
|
||||||
|
const overIndex = target.indexOf(overId as DashboardWidgetId);
|
||||||
|
target.splice(overIndex >= 0 ? overIndex : target.length, 0, activeId as DashboardWidgetId);
|
||||||
|
}
|
||||||
|
return { ...layout, [from]: source, [to]: target };
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableWidget({
|
||||||
|
id,
|
||||||
|
span,
|
||||||
|
animate,
|
||||||
|
resizable,
|
||||||
|
onToggleSpan,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
span: 1 | 2;
|
||||||
|
animate: boolean;
|
||||||
|
resizable: boolean;
|
||||||
|
onToggleSpan: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
isOver,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(animate && !isDragging ? transform : null),
|
||||||
|
transition: animate ? transition : undefined,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex min-w-0 [&>*]:flex-1 [&>*]:min-w-0",
|
||||||
|
span === 2 && "lg:col-span-2",
|
||||||
|
isDragging && "opacity-40",
|
||||||
|
isOver && "outline-2 outline-dashed outline-primary/70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOver && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-x-0 -top-1.5 z-20 h-1 rounded bg-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{resizable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleSpan}
|
||||||
|
title={span === 1 ? "Widen to full width" : "Shrink to half width"}
|
||||||
|
aria-label={span === 1 ? `Widen widget ${id}` : `Shrink widget ${id}`}
|
||||||
|
className="absolute right-8 top-1 z-20 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
{span === 1 ? (
|
||||||
|
<Maximize2Icon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Minimize2Icon className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
aria-label={`Drag widget ${id}`}
|
||||||
|
className="absolute right-1 top-1 z-20 cursor-grab rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<GripVerticalIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StaticWidget({ span, children }: { span: 1 | 2; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex min-w-0 [&>*]:flex-1 [&>*]:min-w-0", span === 2 && "lg:col-span-2")}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainZone({
|
||||||
|
layout,
|
||||||
|
nodesById,
|
||||||
|
editable,
|
||||||
|
onToggleSpan,
|
||||||
|
}: {
|
||||||
|
layout: DashboardLayout;
|
||||||
|
nodesById: Map<DashboardWidgetId, ReactNode>;
|
||||||
|
editable: boolean;
|
||||||
|
onToggleSpan?: (id: DashboardWidgetId) => void;
|
||||||
|
}) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: ZONE_MAIN });
|
||||||
|
const items = layout.main;
|
||||||
|
|
||||||
|
const content = editable ? (
|
||||||
|
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||||
|
{items.map((id) => (
|
||||||
|
<SortableWidget
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
span={effectiveSpan(layout, id)}
|
||||||
|
animate={false}
|
||||||
|
resizable
|
||||||
|
onToggleSpan={() => onToggleSpan?.(id)}
|
||||||
|
>
|
||||||
|
{nodesById.get(id) ?? null}
|
||||||
|
</SortableWidget>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
) : (
|
||||||
|
items.map((id) => (
|
||||||
|
<StaticWidget key={id} span={effectiveSpan(layout, id)}>
|
||||||
|
{nodesById.get(id) ?? null}
|
||||||
|
</StaticWidget>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={editable ? setNodeRef : undefined}
|
||||||
|
aria-label="Main widgets"
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2",
|
||||||
|
editable && "rounded-lg",
|
||||||
|
editable && isOver && "ring-1 ring-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarZone({
|
||||||
|
layout,
|
||||||
|
nodesById,
|
||||||
|
editable,
|
||||||
|
animate,
|
||||||
|
}: {
|
||||||
|
layout: DashboardLayout;
|
||||||
|
nodesById: Map<DashboardWidgetId, ReactNode>;
|
||||||
|
editable: boolean;
|
||||||
|
animate: boolean;
|
||||||
|
}) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: ZONE_SIDEBAR });
|
||||||
|
const items = layout.sidebar;
|
||||||
|
|
||||||
|
const content = editable ? (
|
||||||
|
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||||
|
{items.map((id) => (
|
||||||
|
<SortableWidget
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
span={1}
|
||||||
|
animate={animate}
|
||||||
|
resizable={false}
|
||||||
|
onToggleSpan={() => undefined}
|
||||||
|
>
|
||||||
|
{nodesById.get(id) ?? null}
|
||||||
|
</SortableWidget>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
) : (
|
||||||
|
items.map((id) => (
|
||||||
|
<StaticWidget key={id} span={1}>
|
||||||
|
{nodesById.get(id) ?? null}
|
||||||
|
</StaticWidget>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={editable ? setNodeRef : undefined}
|
||||||
|
aria-label="Sidebar widgets"
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-col gap-4",
|
||||||
|
editable && "rounded-lg",
|
||||||
|
editable && isOver && "ring-1 ring-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardGrid({ layout: initialLayout, widgets }: DashboardGridProps) {
|
||||||
|
const [layout, setLayout] = useState<DashboardLayout>(initialLayout);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [activeDrag, setActiveDrag] = useState<{ id: string; width: number } | null>(null);
|
||||||
|
const layoutRef = useRef(initialLayout);
|
||||||
|
layoutRef.current = layout;
|
||||||
|
|
||||||
|
const nodesById = useMemo(
|
||||||
|
() => new Map(widgets.map((widget) => [widget.id, widget.node])),
|
||||||
|
[widgets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||||
|
useSensor(KeyboardSensor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarAnimate =
|
||||||
|
!activeDrag || findZone(layout, activeDrag.id) === "sidebar";
|
||||||
|
|
||||||
|
function commitLayout(next: DashboardLayout) {
|
||||||
|
layoutRef.current = next;
|
||||||
|
setLayout(next);
|
||||||
|
setSaving(true);
|
||||||
|
void saveDashboardLayout(next)
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error ?? "Could not save your dashboard layout.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Could not save your dashboard layout.",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragStart(event: DragStartEvent) {
|
||||||
|
setActiveDrag({
|
||||||
|
id: String(event.active.id),
|
||||||
|
width: event.active.rect.current.initial?.width ?? 288,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
setActiveDrag(null);
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const next = placementFor(layoutRef.current, String(active.id), String(over.id));
|
||||||
|
if (!next) return;
|
||||||
|
commitLayout(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragCancel() {
|
||||||
|
setActiveDrag(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSpan(id: DashboardWidgetId) {
|
||||||
|
const current = layoutRef.current;
|
||||||
|
const spans = { ...(current.spans ?? {}) };
|
||||||
|
spans[id] = effectiveSpan(current, id) === 1 ? 2 : 1;
|
||||||
|
commitLayout({ ...current, spans });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLayout() {
|
||||||
|
commitLayout(DEFAULT_DASHBOARD_LAYOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
{editing && (
|
||||||
|
<>
|
||||||
|
{saving && (
|
||||||
|
<span aria-live="polite" className="text-xs text-muted-foreground">
|
||||||
|
Saving...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={resetLayout}>
|
||||||
|
<RotateCcwIcon data-icon="inline-start" />
|
||||||
|
Reset layout
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant={editing ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditing((value) => !value)}
|
||||||
|
>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon data-icon="inline-start" />
|
||||||
|
Done
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LayoutGridIcon data-icon="inline-start" />
|
||||||
|
Customize
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCorners}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-4 xl:col-span-2">
|
||||||
|
<MainZone layout={layout} nodesById={nodesById} editable onToggleSpan={toggleSpan} />
|
||||||
|
</div>
|
||||||
|
<SidebarZone
|
||||||
|
layout={layout}
|
||||||
|
nodesById={nodesById}
|
||||||
|
editable
|
||||||
|
animate={sidebarAnimate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DragOverlay>
|
||||||
|
{activeDrag ? (
|
||||||
|
<div
|
||||||
|
style={{ width: activeDrag.width }}
|
||||||
|
className="flex items-center gap-2 rounded-lg border bg-card px-3 py-2.5 shadow-lg"
|
||||||
|
>
|
||||||
|
<GripVerticalIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{WIDGET_LABELS[activeDrag.id as DashboardWidgetId]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-4 xl:col-span-2">
|
||||||
|
<MainZone layout={layout} nodesById={nodesById} editable={false} />
|
||||||
|
</div>
|
||||||
|
<SidebarZone
|
||||||
|
layout={layout}
|
||||||
|
nodesById={nodesById}
|
||||||
|
editable={false}
|
||||||
|
animate={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Drag cards by the handle to rearrange. Changes save automatically.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { markNotificationsRead } from "@/app/(frontend)/notifications/actions";
|
||||||
|
|
||||||
|
interface MarkNotificationReadButtonProps {
|
||||||
|
readonly notificationId: number;
|
||||||
|
readonly title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline "mark as read" affordance for dashboard notification rows. Calls the
|
||||||
|
* shared mark-read server action (scoped to the signed-in user's own
|
||||||
|
* notifications) and refreshes the server-rendered widget afterwards so the
|
||||||
|
* unread badge and row styling update.
|
||||||
|
*/
|
||||||
|
export function MarkNotificationReadButton({
|
||||||
|
notificationId,
|
||||||
|
title,
|
||||||
|
}: MarkNotificationReadButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function markRead() {
|
||||||
|
if (pending) return;
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
const result = await markNotificationsRead([notificationId]);
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error ?? "Failed to update notifications.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to update notifications.");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Mark as read"
|
||||||
|
aria-label={`Mark "${title}" as read`}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void markRead()}
|
||||||
|
className="mt-1 shrink-0 rounded-sm p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Link from "next/link";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import {
|
import {
|
||||||
MissionCard,
|
MissionCard,
|
||||||
|
|
@ -7,24 +8,31 @@ import { CrosshairIcon } from "lucide-react";
|
||||||
|
|
||||||
interface MissionBriefingProps {
|
interface MissionBriefingProps {
|
||||||
readonly mission: MissionCardProps["mission"] | null;
|
readonly mission: MissionCardProps["mission"] | null;
|
||||||
|
readonly heading?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MissionBriefing({ mission }: MissionBriefingProps) {
|
export function MissionBriefing({ mission, heading = "Next Mission" }: MissionBriefingProps) {
|
||||||
if (!mission) {
|
|
||||||
return (
|
return (
|
||||||
<Item variant="outline">
|
<Item variant="outline" className="items-start content-start">
|
||||||
<ItemHeader>
|
<ItemHeader>
|
||||||
<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" />
|
||||||
<ItemTitle>Mission Briefing</ItemTitle>
|
<ItemTitle>{heading}</ItemTitle>
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/intelligence/missions"
|
||||||
|
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
|
{mission ? (
|
||||||
|
<MissionCard mission={mission} showFullDetails />
|
||||||
|
) : (
|
||||||
<p className="text-sm text-muted-foreground italic">No missions currently scheduled.</p>
|
<p className="text-sm text-muted-foreground italic">No missions currently scheduled.</p>
|
||||||
|
)}
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return <MissionCard mission={mission} showFullDetails={true} />;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
79
src/components/frontend/dashboard/MyTickets.tsx
Normal file
79
src/components/frontend/dashboard/MyTickets.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Ticket } from "@/payload-types";
|
||||||
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { ChevronRightIcon, LifeBuoyIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
CLOSING_STATUSES,
|
||||||
|
STATUS_STYLES,
|
||||||
|
statusLabel,
|
||||||
|
} from "@/lib/tickets/ticketMeta";
|
||||||
|
|
||||||
|
interface MyTicketsProps {
|
||||||
|
readonly tickets: Ticket[];
|
||||||
|
readonly openCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMins < 1) return "just now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyTickets({ tickets, openCount }: MyTicketsProps) {
|
||||||
|
return (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LifeBuoyIcon className="size-4 text-muted-foreground" />
|
||||||
|
<ItemTitle>My Tickets</ItemTitle>
|
||||||
|
{openCount > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">{openCount} open</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/helpdesk"
|
||||||
|
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
{tickets.length === 0 ? (
|
||||||
|
<p className="text-sm italic text-muted-foreground">No tickets yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{tickets.map((ticket) => (
|
||||||
|
<Link
|
||||||
|
key={ticket.id}
|
||||||
|
href={`/helpdesk/${ticket.id}`}
|
||||||
|
className="group flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${STATUS_STYLES[ticket.status]}`}
|
||||||
|
>
|
||||||
|
{statusLabel(ticket.status)}
|
||||||
|
</span>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span className="truncate text-sm font-medium transition-colors group-hover:text-primary">
|
||||||
|
{ticket.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Updated {timeAgo(ticket.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/components/frontend/dashboard/OpenNegotiations.tsx
Normal file
95
src/components/frontend/dashboard/OpenNegotiations.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { MarketListing, MarketNegotiation } from "@/payload-types";
|
||||||
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { ChevronRightIcon, HandshakeIcon } from "lucide-react";
|
||||||
|
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
|
||||||
|
interface OpenNegotiationsProps {
|
||||||
|
readonly negotiations: MarketNegotiation[];
|
||||||
|
readonly currentUserId: number;
|
||||||
|
readonly currencyConfig: CurrencyConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listingOf(negotiation: MarketNegotiation): MarketListing | null {
|
||||||
|
return typeof negotiation.listing === "object" && negotiation.listing
|
||||||
|
? (negotiation.listing as MarketListing)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetNameOf(listing: MarketListing | null): string {
|
||||||
|
const asset = listing?.asset;
|
||||||
|
return typeof asset === "object" && asset ? asset.name : "Unknown item";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buyerIdOf(negotiation: MarketNegotiation): number | null {
|
||||||
|
return typeof negotiation.buyer === "object" && negotiation.buyer
|
||||||
|
? negotiation.buyer.id
|
||||||
|
: negotiation.buyer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenNegotiations({
|
||||||
|
negotiations,
|
||||||
|
currentUserId,
|
||||||
|
currencyConfig,
|
||||||
|
}: OpenNegotiationsProps) {
|
||||||
|
// Deliberately hidden when there is nothing on the table (same convention as
|
||||||
|
// NegotiationPanel on the market page).
|
||||||
|
if (negotiations.length === 0) return null;
|
||||||
|
|
||||||
|
const fmt = (n: number) => formatAmount(n, currencyConfig);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HandshakeIcon className="size-4 text-muted-foreground" />
|
||||||
|
<ItemTitle>Negotiations</ItemTitle>
|
||||||
|
<span className="text-xs text-muted-foreground">{negotiations.length} open</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/logistics/market"
|
||||||
|
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
Market
|
||||||
|
</Link>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{negotiations.map((negotiation) => {
|
||||||
|
const listing = listingOf(negotiation);
|
||||||
|
const role = buyerIdOf(negotiation) === currentUserId ? "buyer" : "seller";
|
||||||
|
const theirProposal =
|
||||||
|
(role === "buyer" && negotiation.proposedBy === "seller") ||
|
||||||
|
(role === "seller" && negotiation.proposedBy === "buyer");
|
||||||
|
const quantity = negotiation.quantity ?? listing?.quantity ?? 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={negotiation.id}
|
||||||
|
href="/logistics/market"
|
||||||
|
className="group flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<HandshakeIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium transition-colors group-hover:text-primary">
|
||||||
|
{assetNameOf(listing)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{role === "buyer" ? "Buying" : "Selling"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{fmt(negotiation.amount ?? 0)} each · {quantity}× ·{" "}
|
||||||
|
{theirProposal ? "their offer" : "your offer"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ export function ProfileSummary({
|
||||||
assignments,
|
assignments,
|
||||||
}: ProfileSummaryProps) {
|
}: ProfileSummaryProps) {
|
||||||
return (
|
return (
|
||||||
<Item variant="outline">
|
<Item variant="outline" className="items-start content-start">
|
||||||
<ItemHeader>
|
<ItemHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserIcon className="size-4 text-muted-foreground" />
|
<UserIcon className="size-4 text-muted-foreground" />
|
||||||
|
|
|
||||||
99
src/components/frontend/dashboard/RecentNotifications.tsx
Normal file
99
src/components/frontend/dashboard/RecentNotifications.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { UserNotification } from "@/payload-types";
|
||||||
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { BellIcon } from "lucide-react";
|
||||||
|
import { MarkNotificationReadButton } from "@/components/frontend/dashboard/MarkNotificationReadButton";
|
||||||
|
|
||||||
|
interface RecentNotificationsProps {
|
||||||
|
readonly notifications: UserNotification[];
|
||||||
|
readonly unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMins < 1) return "just now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentNotifications({
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
}: RecentNotificationsProps) {
|
||||||
|
// Hidden entirely when the inbox is empty; an empty notifications box adds
|
||||||
|
// nothing to the dashboard.
|
||||||
|
if (notifications.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BellIcon className="size-4 text-muted-foreground" />
|
||||||
|
<ItemTitle>Notifications</ItemTitle>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">{unreadCount} unread</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/notifications"
|
||||||
|
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
Inbox
|
||||||
|
</Link>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{notifications.map((notification) => {
|
||||||
|
const unread = !notification.read;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
className="group flex items-start gap-1 rounded-md p-2 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={notification.link ?? "/notifications"}
|
||||||
|
className="flex min-w-0 flex-1 items-start gap-3"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`mt-1.5 size-2 shrink-0 rounded-full ${
|
||||||
|
unread ? "bg-sky-400" : "border border-border bg-transparent"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`truncate text-sm ${unread ? "font-medium transition-colors group-hover:text-primary" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{notification.title}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||||
|
{timeAgo(notification.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{notification.message && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{notification.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{unread && (
|
||||||
|
<MarkNotificationReadButton
|
||||||
|
notificationId={notification.id}
|
||||||
|
title={notification.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/frontend/dashboard/WalletWidget.tsx
Normal file
53
src/components/frontend/dashboard/WalletWidget.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { BankAccount } from "@/payload-types";
|
||||||
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { WalletIcon } from "lucide-react";
|
||||||
|
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { MyWalletCard } from "@/components/frontend/banking/MyWalletCard";
|
||||||
|
|
||||||
|
interface WalletWidgetProps {
|
||||||
|
readonly account: BankAccount | null;
|
||||||
|
readonly currencyConfig: CurrencyConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalletWidget({ account, currencyConfig }: WalletWidgetProps) {
|
||||||
|
if (!account) {
|
||||||
|
return <MyWalletCard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WalletIcon className="size-4 text-muted-foreground" />
|
||||||
|
<ItemTitle>Wallet</ItemTitle>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/logistics/banking"
|
||||||
|
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
Banking
|
||||||
|
</Link>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<Link
|
||||||
|
href={`/logistics/banking/${account.id}`}
|
||||||
|
className="group flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="truncate text-sm font-medium transition-colors group-hover:text-primary">
|
||||||
|
{account.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Personal account
|
||||||
|
{account.status && account.status !== "open" ? ` · ${account.status}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-mono text-xl font-semibold tabular-nums">
|
||||||
|
{formatAmount(account.balance ?? 0, currencyConfig)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/frontend/intelligence/CampaignsList.tsx
Normal file
113
src/components/frontend/intelligence/CampaignsList.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
export interface CampaignListEntry {
|
||||||
|
readonly id: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly status: "concept" | "inProgress" | "completed";
|
||||||
|
readonly campaignMode: "official" | "custom";
|
||||||
|
readonly startDate?: string | null;
|
||||||
|
readonly endDate?: string | null;
|
||||||
|
readonly coverImageUrl: string | null;
|
||||||
|
readonly missionCount: number;
|
||||||
|
readonly completedMissions: number;
|
||||||
|
readonly avgPlayers: number;
|
||||||
|
readonly avgDurationMinutes: number;
|
||||||
|
readonly ownerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignsListProps {
|
||||||
|
readonly campaigns: CampaignListEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CampaignsList({ campaigns }: CampaignsListProps) {
|
||||||
|
const [hideCompleted, setHideCompleted] = useState(false);
|
||||||
|
|
||||||
|
const visible = hideCompleted
|
||||||
|
? campaigns.filter((campaign) => campaign.status !== "completed")
|
||||||
|
: campaigns;
|
||||||
|
const officialCampaigns = visible.filter(
|
||||||
|
(campaign) => campaign.campaignMode === "official",
|
||||||
|
);
|
||||||
|
const communityCampaigns = visible.filter(
|
||||||
|
(campaign) => campaign.campaignMode !== "official",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Switch
|
||||||
|
id="hide-completed-campaigns"
|
||||||
|
checked={hideCompleted}
|
||||||
|
onCheckedChange={setHideCompleted}
|
||||||
|
aria-label="Hide completed campaigns"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="hide-completed-campaigns"
|
||||||
|
className="cursor-pointer select-none text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Hide completed
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{visible.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{officialCampaigns.length > 0 && (
|
||||||
|
<section className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
|
<h2 className="text-xl font-semibold uppercase tracking-wider text-white">
|
||||||
|
Official Campaigns
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Command-sanctioned operations at the heart of the unit's story.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 w-full max-w-6xl mx-auto">
|
||||||
|
{officialCampaigns.map((campaign) => (
|
||||||
|
<CampaignCard key={campaign.id} campaign={campaign} size="lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{officialCampaigns.length > 0 && communityCampaigns.length > 0 && (
|
||||||
|
<hr className="w-48 mx-auto border-border/60" />
|
||||||
|
)}
|
||||||
|
{communityCampaigns.length > 0 && (
|
||||||
|
<section className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Community Campaigns
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Player-made side operations and stories, open to everyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full max-w-6xl mx-auto">
|
||||||
|
{communityCampaigns.map((campaign) => (
|
||||||
|
<CampaignCard key={campaign.id} campaign={campaign} size="sm" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : campaigns.length > 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">No campaigns match the current filter.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setHideCompleted(false)}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Show completed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">No campaigns found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="item-content"
|
data-slot="item-content"
|
||||||
className={cn("flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", className)}
|
className={cn("flex min-w-0 flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
136
src/lib/dashboard/layout.ts
Normal file
136
src/lib/dashboard/layout.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
/**
|
||||||
|
* Dashboard widget registry and layout vocabulary. Pure module (no Payload
|
||||||
|
* import) so both server pages and client components can use it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DASHBOARD_WIDGET_IDS = [
|
||||||
|
"profile-summary",
|
||||||
|
"mission-briefing",
|
||||||
|
"recent-events",
|
||||||
|
"quick-stats",
|
||||||
|
"wallet",
|
||||||
|
"negotiations",
|
||||||
|
"my-tickets",
|
||||||
|
"notifications",
|
||||||
|
"upcoming-missions",
|
||||||
|
"active-campaigns",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number];
|
||||||
|
|
||||||
|
export type DashboardZone = "main" | "sidebar";
|
||||||
|
|
||||||
|
export interface DashboardLayout {
|
||||||
|
main: DashboardWidgetId[];
|
||||||
|
sidebar: DashboardWidgetId[];
|
||||||
|
/** Per-widget main-grid span overrides (1 = half width, 2 = full width). */
|
||||||
|
spans?: Partial<Record<DashboardWidgetId, 1 | 2>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WIDGET_LABELS: Record<DashboardWidgetId, string> = {
|
||||||
|
"profile-summary": "Service Summary",
|
||||||
|
"mission-briefing": "Mission Briefing",
|
||||||
|
"recent-events": "Recent Events",
|
||||||
|
"quick-stats": "Quick Stats",
|
||||||
|
wallet: "Wallet",
|
||||||
|
negotiations: "Negotiations",
|
||||||
|
"my-tickets": "My Tickets",
|
||||||
|
notifications: "Notifications",
|
||||||
|
"upcoming-missions": "Upcoming Missions",
|
||||||
|
"active-campaigns": "Active Campaigns",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||||
|
main: ["profile-summary", "mission-briefing", "recent-events", "quick-stats"],
|
||||||
|
sidebar: [
|
||||||
|
"wallet",
|
||||||
|
"negotiations",
|
||||||
|
"my-tickets",
|
||||||
|
"notifications",
|
||||||
|
"upcoming-missions",
|
||||||
|
"active-campaigns",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column span of each widget inside the main zone's two-column grid. The
|
||||||
|
* sidebar ignores spans (single column). New widgets must be added here;
|
||||||
|
* normalizer defaults missing entries to 1.
|
||||||
|
*/
|
||||||
|
export const WIDGET_SPANS: Partial<Record<DashboardWidgetId, 1 | 2>> = {
|
||||||
|
"recent-events": 2,
|
||||||
|
"quick-stats": 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function widgetSpan(id: DashboardWidgetId): 1 | 2 {
|
||||||
|
return WIDGET_SPANS[id] ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveSpan(layout: DashboardLayout, id: DashboardWidgetId): 1 | 2 {
|
||||||
|
return layout.spans?.[id] ?? widgetSpan(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultZoneOf(id: DashboardWidgetId): DashboardZone {
|
||||||
|
return DEFAULT_DASHBOARD_LAYOUT.sidebar.includes(id) ? "sidebar" : "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIDGET_ID_STRINGS: readonly string[] = DASHBOARD_WIDGET_IDS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes an arbitrary stored layout into a valid DashboardLayout: drops
|
||||||
|
* unknown/duplicate ids, keeps known ones in the user's order, and appends
|
||||||
|
* any known widget the saved layout does not mention to its default zone, so
|
||||||
|
* newly added widgets appear without a migration.
|
||||||
|
*/
|
||||||
|
export function normalizeDashboardLayout(raw: unknown): DashboardLayout {
|
||||||
|
const seen = new Set<DashboardWidgetId>();
|
||||||
|
const pickIds = (value: unknown): DashboardWidgetId[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const ids: DashboardWidgetId[] = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
if (typeof entry !== "string" || !WIDGET_ID_STRINGS.includes(entry)) continue;
|
||||||
|
const id = entry as DashboardWidgetId;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
const source = (raw ?? null) as {
|
||||||
|
main?: unknown;
|
||||||
|
sidebar?: unknown;
|
||||||
|
spans?: unknown;
|
||||||
|
} | null;
|
||||||
|
const main = pickIds(source?.main);
|
||||||
|
const sidebar = pickIds(source?.sidebar);
|
||||||
|
|
||||||
|
let spans: Partial<Record<DashboardWidgetId, 1 | 2>> | undefined;
|
||||||
|
if (source?.spans && typeof source.spans === "object" && !Array.isArray(source.spans)) {
|
||||||
|
const cleaned: Partial<Record<DashboardWidgetId, 1 | 2>> = {};
|
||||||
|
for (const [key, value] of Object.entries(source.spans as Record<string, unknown>)) {
|
||||||
|
if (!WIDGET_ID_STRINGS.includes(key)) continue;
|
||||||
|
if (value !== 1 && value !== 2) continue;
|
||||||
|
cleaned[key as DashboardWidgetId] = value;
|
||||||
|
}
|
||||||
|
if (Object.keys(cleaned).length > 0) spans = cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of DASHBOARD_WIDGET_IDS) {
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
if (defaultZoneOf(id) === "main") main.push(id);
|
||||||
|
else sidebar.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { main, sidebar, spans };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zoneIdOfWidget(
|
||||||
|
layout: DashboardLayout,
|
||||||
|
id: DashboardWidgetId,
|
||||||
|
): DashboardZone | null {
|
||||||
|
if (layout.main.includes(id)) return "main";
|
||||||
|
if (layout.sidebar.includes(id)) return "sidebar";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -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;`)
|
||||||
|
}
|
||||||
24494
src/migrations/20260910_222518_add_dashboard_layout_preference.json
Normal file
24494
src/migrations/20260910_222518_add_dashboard_layout_preference.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 "users" ADD COLUMN "preferences_dashboard_layout" jsonb;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "users" DROP COLUMN "preferences_dashboard_layout";`)
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,8 @@ import * as migration_20260908_193107_add_creator_xp_bank_field from './20260908
|
||||||
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field';
|
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field';
|
||||||
import * as migration_20260909_153427_add_opord_recall_best_times from './20260909_153427_add_opord_recall_best_times';
|
import * as migration_20260909_153427_add_opord_recall_best_times from './20260909_153427_add_opord_recall_best_times';
|
||||||
import * as migration_20260909_223955_add_voice_script_draft_status from './20260909_223955_add_voice_script_draft_status';
|
import * as migration_20260909_223955_add_voice_script_draft_status from './20260909_223955_add_voice_script_draft_status';
|
||||||
|
import * as migration_20260910_221018_make_missions_codename_optional from './20260910_221018_make_missions_codename_optional';
|
||||||
|
import * as migration_20260910_222518_add_dashboard_layout_preference from './20260910_222518_add_dashboard_layout_preference';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -310,6 +312,16 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260909_223955_add_voice_script_draft_status.up,
|
up: migration_20260909_223955_add_voice_script_draft_status.up,
|
||||||
down: migration_20260909_223955_add_voice_script_draft_status.down,
|
down: migration_20260909_223955_add_voice_script_draft_status.down,
|
||||||
name: '20260909_223955_add_voice_script_draft_status'
|
name: '20260909_223955_add_voice_script_draft_status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260910_221018_make_missions_codename_optional.up,
|
||||||
|
down: migration_20260910_221018_make_missions_codename_optional.down,
|
||||||
|
name: '20260910_221018_make_missions_codename_optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260910_222518_add_dashboard_layout_preference.up,
|
||||||
|
down: migration_20260910_222518_add_dashboard_layout_preference.down,
|
||||||
|
name: '20260910_222518_add_dashboard_layout_preference'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1392,6 +1392,17 @@ export interface User {
|
||||||
)[]
|
)[]
|
||||||
| null;
|
| null;
|
||||||
};
|
};
|
||||||
|
dashboard?: {
|
||||||
|
layout?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -2388,9 +2399,9 @@ export interface Mission {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
codeName: string;
|
codeName?: string | null;
|
||||||
/**
|
/**
|
||||||
* A summary of the mission. This is not the same as a briefing; instead, it describes the mission's theme and concept to the unit as a whole.
|
* A summary of the mission. This is not the same as a briefing; instead, it describes the mission's theme and concept to the unit as a whole.
|
||||||
*/
|
*/
|
||||||
|
|
@ -4433,6 +4444,11 @@ export interface UsersSelect<T extends boolean = true> {
|
||||||
enabled?: T;
|
enabled?: T;
|
||||||
mutedTypes?: T;
|
mutedTypes?: T;
|
||||||
};
|
};
|
||||||
|
dashboard?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
layout?: T;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
|
|
|
||||||
|
|
@ -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