From 0771424446d15c96391411f21dc67ec6503d06f5 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 26 Aug 2026 00:53:17 -0400 Subject: [PATCH] feat(campaigns): let members create and own campaigns --- .../intelligence/campaigns/[id]/page.tsx | 35 +++- .../intelligence/campaigns/page.tsx | 24 ++- src/collections/intelligence/Campaigns.ts | 20 ++- .../frontend/intelligence/CampaignCard.tsx | 14 +- .../intelligence/CreateCampaignDialog.tsx | 164 ++++++++++++++++++ .../frontend/intelligence/create.ts | 72 ++++++++ src/permissions/index.ts | 6 + src/tools/seed/seedRoles.ts | 1 + src/utils/access-control/hasPermission.ts | 55 +++++- 9 files changed, 367 insertions(+), 24 deletions(-) create mode 100644 src/components/frontend/intelligence/CreateCampaignDialog.tsx create mode 100644 src/components/frontend/intelligence/create.ts diff --git a/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx b/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx index 4ef687f..cb35db0 100644 --- a/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx +++ b/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx @@ -42,9 +42,24 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP collection: "campaigns", id: Number(id), depth: 1, + select: { + id: true, + name: true, + summary: true, + status: true, + startDate: true, + endDate: true, + owner: true, + }, }) .catch(() => null); + const owner = campaign?.owner; + const ownerName = + owner && typeof owner === "object" && "displayName" in owner + ? owner.displayName + : "Unknown"; + if (!campaign) { notFound(); } @@ -237,15 +252,17 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP {count} ))} - {campaign.startDate && ( -
- Duration - - {startDate} - {endDate ? ` — ${endDate}` : " — Present"} - -
- )} +
+ Duration + + {startDate} + {endDate ? ` — ${endDate}` : " — Present"} + +
+
+ Owner + {ownerName} +
diff --git a/src/app/(frontend)/intelligence/campaigns/page.tsx b/src/app/(frontend)/intelligence/campaigns/page.tsx index bf8e2de..a782355 100644 --- a/src/app/(frontend)/intelligence/campaigns/page.tsx +++ b/src/app/(frontend)/intelligence/campaigns/page.tsx @@ -2,6 +2,7 @@ import config from "@payload-config"; import { getPayload } from "payload"; import { ShieldIcon } from "lucide-react"; import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard"; +import { CreateCampaignDialog } from "@/components/frontend/intelligence/CreateCampaignDialog"; import type { Media } from "@/payload-types"; import { Button } from "@/components/ui/button"; @@ -17,6 +18,15 @@ export default async function CampaignsPage() { sort: "-createdAt", limit: 100, depth: 1, + select: { + id: true, + name: true, + summary: true, + status: true, + startDate: true, + endDate: true, + owner: true, + }, }); const campaigns = campaignsRes.docs; @@ -60,12 +70,19 @@ export default async function CampaignsPage() { : null; const coverImageUrl = coverImage?.url ?? null; + const owner = (campaign as any).owner; + const ownerName = + owner && typeof owner === "object" && "displayName" in owner + ? owner.displayName + : "Unknown"; + return { ...campaign, coverImageUrl, missionCount, avgPlayers: missionCount === 0 ? 0 : totalPlayers / missionCount, avgDurationMinutes: missionCount === 0 ? 0 : totalDurationMinutes / missionCount, + ownerName, }; }), ); @@ -85,12 +102,7 @@ export default async function CampaignsPage() {
-

- Are you a Zeus and want to start your own campaign? -

- +
{sorted.length > 0 ? ( diff --git a/src/collections/intelligence/Campaigns.ts b/src/collections/intelligence/Campaigns.ts index 2d29091..5e8affe 100644 --- a/src/collections/intelligence/Campaigns.ts +++ b/src/collections/intelligence/Campaigns.ts @@ -1,5 +1,6 @@ import { CollectionConfig } from "payload"; -import { requirePermission } from "@/utils/access-control/hasPermission"; +import { requirePermission, requireCampaignOwnership } from "@/utils/access-control/hasPermission"; +import type { User } from "@/payload-types"; export const Campaigns: CollectionConfig = { slug: "campaigns", @@ -8,10 +9,10 @@ export const Campaigns: CollectionConfig = { useAsTitle: "name", }, access: { - create: requirePermission("campaigns:create"), - update: requirePermission("campaigns:update"), - delete: requirePermission("campaigns:delete"), - read: ({ req }) => !!req.user, + create: ({ req }) => !!req.user, // Any logged-in user can create (becomes owner) + read: ({ req }) => !!req.user, // Anyone logged in can read + update: requireCampaignOwnership("campaigns:update"), + delete: requireCampaignOwnership("campaigns:delete"), }, fields: [ { @@ -67,5 +68,14 @@ export const Campaigns: CollectionConfig = { "Background image for the campaign selector card on the campaigns page. Renders translucent with hover effects. Recommended aspect ratio: 16:9 or square.", }, }, + { + name: "owner", + type: "relationship", + relationTo: "users", + admin: { + position: "sidebar", + description: "User who created this campaign. Set automatically on create.", + }, + }, ], }; diff --git a/src/components/frontend/intelligence/CampaignCard.tsx b/src/components/frontend/intelligence/CampaignCard.tsx index 0ad8398..cab9171 100644 --- a/src/components/frontend/intelligence/CampaignCard.tsx +++ b/src/components/frontend/intelligence/CampaignCard.tsx @@ -51,6 +51,12 @@ export function CampaignCard({ campaign }: CampaignCardProps) { roundedDuration >= 60 ? `${durationHours}h ${durationMinutes}m` : `${roundedDuration}m`; const playersLabel = Number.isInteger(avgPlayers) ? avgPlayers.toString() : avgPlayers.toFixed(1); + const owner = (campaign as any).owner; + const ownerName = + owner && typeof owner === "object" && "displayName" in owner + ? owner.displayName + : "Unknown"; + return (