diff --git a/db/schema.sql b/db/schema.sql index a57fa50..265241d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,7 +1,7 @@ -\restrict Dg0ksc8IIsh0chvkrHOQy7LtnwsmjF3WyModqIXmKLfbi99THWiNyWMb9U47Fk5 +\restrict EIvSglJHygsNcIKc7gr1LRqCWzak1b2BYTPO17s7oUnsrweJPJORd0ywNzjCr4G -- Dumped from database version 16.10 --- Dumped by pg_dump version 18.0 +-- Dumped by pg_dump version 18.1 SET statement_timeout = 0; SET lock_timeout = 0; @@ -48,6 +48,59 @@ CREATE TABLE public.auth_tokens ( ); +-- +-- Name: campaign_characters; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.campaign_characters ( + id character varying(26) NOT NULL, + campaign_id character varying(26) NOT NULL, + character_id character varying(26) NOT NULL, + revealed_at timestamp with time zone, + added_by character varying(26) NOT NULL, + added_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: campaign_members; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.campaign_members ( + id character varying(26) NOT NULL, + campaign_id character varying(26) NOT NULL, + user_id character varying(26) NOT NULL, + role text NOT NULL, + invited_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + invited_by character varying(26) NOT NULL, + accepted_at timestamp with time zone, + declined_at timestamp with time zone, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + invite_token character varying(26), + deleted_at timestamp without time zone, + CONSTRAINT campaign_members_not_both_accepted_and_declined CHECK ((NOT ((accepted_at IS NOT NULL) AND (declined_at IS NOT NULL)))), + CONSTRAINT campaign_members_role_check CHECK ((role = ANY (ARRAY['dm'::text, 'player'::text, 'viewer'::text]))) +); + + +-- +-- Name: campaigns; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.campaigns ( + id character varying(26) NOT NULL, + name text NOT NULL, + description text, + created_by character varying(26) NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + archived_at timestamp with time zone +); + + -- -- Name: char_abilities; Type: TABLE; Schema: public; Owner: - -- @@ -443,7 +496,8 @@ CREATE TABLE public.users ( id character varying(26) NOT NULL, email text NOT NULL, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + name text ); @@ -455,6 +509,46 @@ ALTER TABLE ONLY public.auth_tokens ADD CONSTRAINT auth_tokens_pkey PRIMARY KEY (id); +-- +-- Name: campaign_characters campaign_characters_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaign_characters + ADD CONSTRAINT campaign_characters_pkey PRIMARY KEY (id); + + +-- +-- Name: campaign_characters campaign_characters_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaign_characters + ADD CONSTRAINT campaign_characters_unique UNIQUE (campaign_id, character_id); + + +-- +-- Name: campaign_members campaign_members_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaign_members + ADD CONSTRAINT campaign_members_pkey PRIMARY KEY (id); + + +-- +-- Name: campaign_members campaign_members_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaign_members + ADD CONSTRAINT campaign_members_unique UNIQUE (campaign_id, user_id); + + +-- +-- Name: campaigns campaigns_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaigns + ADD CONSTRAINT campaigns_pkey PRIMARY KEY (id); + + -- -- Name: char_abilities char_abilities_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -660,6 +754,55 @@ CREATE INDEX idx_auth_tokens_expires_at ON public.auth_tokens USING btree (expir CREATE INDEX idx_auth_tokens_session_token_hash ON public.auth_tokens USING btree (session_token_hash); +-- +-- Name: idx_campaign_characters_campaign_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_campaign_characters_campaign_id ON public.campaign_characters USING btree (campaign_id); + + +-- +-- Name: idx_campaign_characters_character_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_campaign_characters_character_id ON public.campaign_characters USING btree (character_id); + + +-- +-- Name: idx_campaign_members_campaign_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_campaign_members_campaign_id ON public.campaign_members USING btree (campaign_id); + + +-- +-- Name: idx_campaign_members_invite_token; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_campaign_members_invite_token ON public.campaign_members USING btree (invite_token); + + +-- +-- Name: idx_campaign_members_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_campaign_members_user_id ON public.campaign_members USING btree (user_id); + + +-- +-- Name: idx_campaigns_created_by; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_campaigns_created_by ON public.campaigns USING btree (created_by); + + +-- +-- Name: idx_campaigns_created_by_archived_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_campaigns_created_by_archived_at ON public.campaigns USING btree (created_by, archived_at); + + -- -- Name: idx_char_abilities_char_id_ability_created_at; Type: INDEX; Schema: public; Owner: - -- @@ -863,6 +1006,27 @@ CREATE INDEX idx_uploads_user_id ON public.uploads USING btree (user_id); CREATE UNIQUE INDEX idx_users_email ON public.users USING btree (email); +-- +-- Name: campaign_characters campaign_characters_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER campaign_characters_updated_at BEFORE UPDATE ON public.campaign_characters FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + + +-- +-- Name: campaign_members campaign_members_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER campaign_members_updated_at BEFORE UPDATE ON public.campaign_members FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + + +-- +-- Name: campaigns campaigns_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER campaigns_updated_at BEFORE UPDATE ON public.campaigns FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + + -- -- Name: char_abilities char_abilities_updated_at; Type: TRIGGER; Schema: public; Owner: - -- @@ -975,6 +1139,62 @@ CREATE TRIGGER update_char_notes_updated_at BEFORE UPDATE ON public.char_notes F CREATE TRIGGER users_updated_at BEFORE UPDATE ON public.users FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); +-- +-- Name: campaign_characters campaign_characters_added_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaign_characters + ADD CONSTRAINT campaign_characters_added_by_fkey FOREIGN KEY (added_by) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: campaign_characters campaign_characters_campaign_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaign_characters + ADD CONSTRAINT campaign_characters_campaign_id_fkey FOREIGN KEY (campaign_id) REFERENCES public.campaigns(id) ON DELETE CASCADE; + + +-- +-- Name: campaign_characters campaign_characters_character_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaign_characters + ADD CONSTRAINT campaign_characters_character_id_fkey FOREIGN KEY (character_id) REFERENCES public.characters(id) ON DELETE CASCADE; + + +-- +-- Name: campaign_members campaign_members_campaign_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaign_members + ADD CONSTRAINT campaign_members_campaign_id_fkey FOREIGN KEY (campaign_id) REFERENCES public.campaigns(id) ON DELETE CASCADE; + + +-- +-- Name: campaign_members campaign_members_invited_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaign_members + ADD CONSTRAINT campaign_members_invited_by_fkey FOREIGN KEY (invited_by) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: campaign_members campaign_members_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaign_members + ADD CONSTRAINT campaign_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: campaigns campaigns_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.campaigns + ADD CONSTRAINT campaigns_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE CASCADE; + + -- -- Name: char_abilities char_abilities_character_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -1163,7 +1383,7 @@ ALTER TABLE ONLY public.items -- PostgreSQL database dump complete -- -\unrestrict Dg0ksc8IIsh0chvkrHOQy7LtnwsmjF3WyModqIXmKLfbi99THWiNyWMb9U47Fk5 +\unrestrict EIvSglJHygsNcIKc7gr1LRqCWzak1b2BYTPO17s7oUnsrweJPJORd0ywNzjCr4G -- @@ -1206,4 +1426,9 @@ INSERT INTO public.schema_migrations (version) VALUES ('20251111221748'), ('20251112060352'), ('20251112060400'), - ('20251112060500'); + ('20251112060500'), + ('20251122030300'), + ('20251125200357'), + ('20251126134731'), + ('20251205001836'), + ('20251209195618'); diff --git a/migrations/20251122030300_create_campaigns.sql b/migrations/20251122030300_create_campaigns.sql new file mode 100644 index 0000000..7c75306 --- /dev/null +++ b/migrations/20251122030300_create_campaigns.sql @@ -0,0 +1,80 @@ +-- migrate:up + +-- Campaigns table +CREATE TABLE campaigns ( + id VARCHAR(26) PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + created_by VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_campaigns_created_by ON campaigns(created_by); + +CREATE TRIGGER campaigns_updated_at + BEFORE UPDATE ON campaigns + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Campaign members (DMs, players, viewers) +CREATE TABLE campaign_members ( + id VARCHAR(26) PRIMARY KEY, + campaign_id VARCHAR(26) NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('dm', 'player', 'viewer')), + invited_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + invited_by VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + accepted_at TIMESTAMPTZ, + declined_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT campaign_members_unique UNIQUE(campaign_id, user_id), + CONSTRAINT campaign_members_not_both_accepted_and_declined CHECK (NOT (accepted_at IS NOT NULL AND declined_at IS NOT NULL)) +); + +CREATE INDEX idx_campaign_members_campaign_id ON campaign_members(campaign_id); +CREATE INDEX idx_campaign_members_user_id ON campaign_members(user_id); + +CREATE TRIGGER campaign_members_updated_at + BEFORE UPDATE ON campaign_members + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Campaign characters (characters added to campaigns) +CREATE TABLE campaign_characters ( + id VARCHAR(26) PRIMARY KEY, + campaign_id VARCHAR(26) NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + character_id VARCHAR(26) NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + revealed_at TIMESTAMPTZ, + added_by VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT campaign_characters_unique UNIQUE(campaign_id, character_id) +); + +CREATE INDEX idx_campaign_characters_campaign_id ON campaign_characters(campaign_id); +CREATE INDEX idx_campaign_characters_character_id ON campaign_characters(character_id); + +CREATE TRIGGER campaign_characters_updated_at + BEFORE UPDATE ON campaign_characters + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- migrate:down + +DROP TRIGGER IF EXISTS campaign_characters_updated_at ON campaign_characters; +DROP INDEX IF EXISTS idx_campaign_characters_character_id; +DROP INDEX IF EXISTS idx_campaign_characters_campaign_id; +DROP TABLE IF EXISTS campaign_characters; + +DROP TRIGGER IF EXISTS campaign_members_updated_at ON campaign_members; +DROP INDEX IF EXISTS idx_campaign_members_user_id; +DROP INDEX IF EXISTS idx_campaign_members_campaign_id; +DROP TABLE IF EXISTS campaign_members; + +DROP TRIGGER IF EXISTS campaigns_updated_at ON campaigns; +DROP INDEX IF EXISTS idx_campaigns_created_by; +DROP TABLE IF EXISTS campaigns; + diff --git a/migrations/20251125200357_add_archived_at_to_campaigns.sql b/migrations/20251125200357_add_archived_at_to_campaigns.sql new file mode 100644 index 0000000..b275707 --- /dev/null +++ b/migrations/20251125200357_add_archived_at_to_campaigns.sql @@ -0,0 +1,8 @@ +-- migrate:up +-- Add archived_at column for soft-delete archiving of campaigns +ALTER TABLE campaigns ADD COLUMN archived_at TIMESTAMPTZ DEFAULT NULL; +CREATE INDEX idx_campaigns_created_by_archived_at ON campaigns(created_by, archived_at); + +-- migrate:down +DROP INDEX IF EXISTS idx_campaigns_created_by_archived_at; +ALTER TABLE campaigns DROP COLUMN archived_at; diff --git a/migrations/20251126134731_add_invite_token_to_members.sql b/migrations/20251126134731_add_invite_token_to_members.sql new file mode 100644 index 0000000..cc7fdc7 --- /dev/null +++ b/migrations/20251126134731_add_invite_token_to_members.sql @@ -0,0 +1,7 @@ +-- migrate:up +ALTER TABLE campaign_members ADD COLUMN invite_token VARCHAR(26); +CREATE INDEX idx_campaign_members_invite_token ON campaign_members(invite_token); + +-- migrate:down +DROP INDEX IF EXISTS idx_campaign_members_invite_token; +ALTER TABLE campaign_members DROP COLUMN invite_token; diff --git a/migrations/20251205001836_add_deleted_at_to_campaign_members.sql b/migrations/20251205001836_add_deleted_at_to_campaign_members.sql new file mode 100644 index 0000000..3526e63 --- /dev/null +++ b/migrations/20251205001836_add_deleted_at_to_campaign_members.sql @@ -0,0 +1,5 @@ +-- migrate:up +ALTER TABLE campaign_members ADD COLUMN deleted_at timestamp; + +-- migrate:down +ALTER TABLE campaign_members DROP COLUMN deleted_at; diff --git a/migrations/20251209195618_add_name_to_users.sql b/migrations/20251209195618_add_name_to_users.sql new file mode 100644 index 0000000..828f5c6 --- /dev/null +++ b/migrations/20251209195618_add_name_to_users.sql @@ -0,0 +1,6 @@ +-- migrate:up +ALTER TABLE users ADD COLUMN name TEXT; + +-- migrate:down +ALTER TABLE users DROP COLUMN name; + diff --git a/src/app.ts b/src/app.ts index d7af1ae..f1e563c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,10 +7,12 @@ import { applyMiddleware } from "./middleware" import { requireAuth } from "./middleware/auth" import { cachingServeStatic } from "./middleware/cachingServeStatic" import { authRoutes } from "./routes/auth" +import { campaignsRoutes } from "./routes/campaigns" import { characterRoutes } from "./routes/character" import { chatRoutes } from "./routes/chat" import { healthRoutes } from "./routes/health" import { indexRoutes } from "./routes/index" +import { profileRoutes } from "./routes/profile" import { spellsRoutes } from "./routes/spells" import { uploadsRoutes } from "./routes/uploads" @@ -49,7 +51,8 @@ export function createApp(db?: SQL) { const user = c.get("user") const currentPage = c.req.path const flash = c.get("flash") - return Layout({ ...props, user, currentPage, flash }) + const notifications = c.get("notifications") + return Layout({ ...props, user, currentPage, flash, notifications }) }) ) @@ -69,7 +72,9 @@ export function createApp(db?: SQL) { const protectedRoutes = new Hono() protectedRoutes.use("*", requireAuth) protectedRoutes.route("/", characterRoutes) + protectedRoutes.route("/", campaignsRoutes) protectedRoutes.route("/", chatRoutes) + protectedRoutes.route("/", profileRoutes) protectedRoutes.route("/", uploadsRoutes) app.route("/", protectedRoutes) diff --git a/src/components/AbilitiesEditForm.tsx b/src/components/AbilitiesEditForm.tsx index 86201f1..eb633ca 100644 --- a/src/components/AbilitiesEditForm.tsx +++ b/src/components/AbilitiesEditForm.tsx @@ -1,7 +1,7 @@ +import { ModalContent } from "@src/components/ui/DetailModal" import { Abilities, type AbilityType } from "@src/lib/dnd" import type { ComputedCharacter } from "@src/services/computeCharacter" import clsx from "clsx" -import { ModalContent } from "./ui/ModalContent" import { ModalForm, ModalFormSubmit } from "./ui/ModalForm" export interface AbilitiesEditFormProps { diff --git a/src/components/AddCharacterToCampaign.tsx b/src/components/AddCharacterToCampaign.tsx new file mode 100644 index 0000000..71048e9 --- /dev/null +++ b/src/components/AddCharacterToCampaign.tsx @@ -0,0 +1,84 @@ +import { CampaignCharacterCard } from "@src/components/ui/CampaignCharacterCard" +import { ModalBody, ModalContent, ModalFooter } from "@src/components/ui/DetailModal" +import type { ListCharacter } from "@src/services/listCharacters" + +export interface AddCharacterToCampaignProps { + campaignId: string + characters: ListCharacter[] + errors?: Record + mode?: "character" | "npc" +} + +export const AddCharacterToCampaign = ({ + campaignId, + characters, + errors, + mode = "character", +}: AddCharacterToCampaignProps) => { + const isNpcMode = mode === "npc" + const title = isNpcMode ? "Add NPC" : "Add Character" + const buttonText = isNpcMode ? "Add as NPC" : "Add to Campaign" + const emptyStateTitle = isNpcMode ? "No Characters Available" : "No Characters Available" + const emptyStateMessage = isNpcMode + ? "Create a character first, then come back here to add them as an NPC." + : "Create a character first, then come back here to add them to the campaign." + const selectionPrompt = isNpcMode + ? "Select a character to add as an NPC:" + : "Select a character to add to this campaign:" + + if (characters.length === 0) { + return ( + + +
+ +
{emptyStateTitle}
+

{emptyStateMessage}

+ + Go to Characters + +
+
+
+ ) + } + + return ( + + + {errors?.general &&
{errors.general}
} +

{selectionPrompt}

+
+ {characters.map((char) => ( +
+ + + +
+ ))} +
+
+ + + +
+ ) +} diff --git a/src/components/AvatarCropper.tsx b/src/components/AvatarCropper.tsx index 2c8bef9..f5e3c6d 100644 --- a/src/components/AvatarCropper.tsx +++ b/src/components/AvatarCropper.tsx @@ -1,5 +1,5 @@ +import { ModalContent } from "@src/components/ui/DetailModal" import type { ComputedCharacter } from "@src/services/computeCharacter" -import { ModalContent } from "./ui/ModalContent" export interface AvatarCropperProps { character: ComputedCharacter @@ -78,7 +78,7 @@ export const AvatarCropper = ({ character, avatarIndex }: AvatarCropperProps) => type="button" class="btn btn-secondary" hx-get={`/characters/${character.id}/avatars`} - hx-target="#editModalContent" + hx-target="#detailModalContent" hx-swap="innerHTML" > Back to Gallery @@ -88,7 +88,7 @@ export const AvatarCropper = ({ character, avatarIndex }: AvatarCropperProps) => class="btn btn-primary" hx-post={action} hx-include="#cropForm" - hx-target="#editModalContent" + hx-target="#detailModalContent" hx-swap="innerHTML" > Save Crop diff --git a/src/components/AvatarDisplay.tsx b/src/components/AvatarDisplay.tsx index 320852e..16e374f 100644 --- a/src/components/AvatarDisplay.tsx +++ b/src/components/AvatarDisplay.tsx @@ -111,10 +111,10 @@ export const AvatarDisplay = ({ class="avatar-button position-relative ratio ratio-1x1 rounded overflow-hidden border-0 p-0" tabindex={0} hx-get={`/characters/${character.id}/avatars`} - hx-target="#editModalContent" + hx-target="#detailModalContent" hx-swap="innerHTML" data-bs-toggle="modal" - data-bs-target="#editModal" + data-bs-target="#detailModal" style={cropStyle.containerStyle} > + {imgAlt} + + ) + } + return ( + {!isReadOnly && ( + + )} diff --git a/src/components/Campaign.tsx b/src/components/Campaign.tsx new file mode 100644 index 0000000..c9428d8 --- /dev/null +++ b/src/components/Campaign.tsx @@ -0,0 +1,666 @@ +import { CampaignCharacterCard } from "@src/components/ui/CampaignCharacterCard" +import { CampaignMemberCard } from "@src/components/ui/CampaignMemberCard" +import { DetailModal } from "@src/components/ui/DetailModal" +import type { ComputedCampaign, ComputedCampaignCharacter } from "@src/services/campaigns/compute" + +export interface CampaignProps { + campaign: ComputedCampaign +} + +interface CharacterCardProps { + character: ComputedCampaignCharacter + campaignId: string + memberId?: string // Member ID for change-role button (not available for NPCs) + canReveal: boolean + canRemove: boolean + canChangeRole: boolean // DM viewing another user's character + isDM: boolean + isCurrentUser: boolean +} + +const CharacterCard = ({ + character, + campaignId, + memberId, + canReveal, + canRemove, + canChangeRole, + isDM, + isCurrentUser, +}: CharacterCardProps) => { + const addedByDisplay = character.added_by_name || character.added_by_email + const subtitle = character.isNPC ? `Added by: ${addedByDisplay}` : `Played by: ${addedByDisplay}` + + // Show "Hidden" badge for hidden NPCs (only visible to DMs) + const badge = + isDM && character.isNPC && !character.revealed_at + ? { text: "Hidden", variant: "secondary" as const } + : undefined + + return ( + + {/* NPC reveal/hide buttons */} + {canReveal && character.isNPC && character.revealed_at && ( + + )} + {canReveal && character.isNPC && !character.revealed_at && ( + + )} + + {/* View button for owner or DM */} + {(isCurrentUser || isDM) && ( + + View + + )} + + {/* Add another character button for owner (non-NPC only) */} + {isCurrentUser && !character.isNPC && ( + + )} + + {/* Change role button for DM viewing another user's character */} + {canChangeRole && memberId && ( + + )} + + {/* Remove button */} + {canRemove && ( + + )} + + ) +} + +export const Campaign = ({ campaign }: CampaignProps) => { + const isDM = campaign.userRole === "dm" + + // Filter members by role, excluding DMs (they have their own section) and viewers + // Also exclude declined members for non-DMs + const allPartyMembers = campaign.members.filter((m) => m.role !== "viewer" && m.role !== "dm") + const partyMembers = allPartyMembers + .filter((m) => isDM || !m.declined_at) // Only DMs see declined members + .sort((a, b) => { + // Sort declined members to the end + if (a.declined_at && !b.declined_at) return 1 + if (!a.declined_at && b.declined_at) return -1 + return 0 + }) + // Filter DMs - show pending/declined only to DMs + const allDMs = campaign.members.filter((m) => m.role === "dm") + const dmMembers = allDMs + .filter((m) => isDM || m.accepted_at) + .sort((a, b) => { + // Sort: accepted first, then pending, then declined + if (a.accepted_at && !b.accepted_at) return -1 + if (!a.accepted_at && b.accepted_at) return 1 + if (a.declined_at && !b.declined_at) return 1 + if (!a.declined_at && b.declined_at) return -1 + return 0 + }) + + // Filter Viewers - show pending/declined only to DMs + const allViewers = campaign.members.filter((m) => m.role === "viewer") + const viewerMembers = allViewers + .filter((m) => isDM || m.accepted_at) + .sort((a, b) => { + // Sort: accepted first, then pending, then declined + if (a.accepted_at && !b.accepted_at) return -1 + if (!a.accepted_at && b.accepted_at) return 1 + if (a.declined_at && !b.declined_at) return 1 + if (!a.declined_at && b.declined_at) return -1 + return 0 + }) + + // Filter NPCs (characters added by DMs) + const allNPCs = campaign.characters.filter((c) => c.isNPC) + const visibleNPCs = allNPCs.filter((npc) => isDM || npc.revealed_at !== null) + + return ( + <> +
+ {/* Campaign Header */} +
+
+
+
+
+ {campaign.name} +
+
+
+

{campaign.name}

+ {campaign.description &&

{campaign.description}

} +
+
+
+
+ + {/* Section 1: Characters & Members (Party) */} +
+
+
+

Characters & Members

+ {campaign.canInviteMembers && ( + + )} +
+ + {partyMembers.length === 0 ? ( +
+

No members yet.

+
+ ) : ( +
+ {partyMembers.map((member) => { + // Find character for this member + const memberChar = campaign.characters.find( + (c) => c.user_id === member.user_id && !c.isNPC + ) + + if (memberChar) { + // Has character - render character-first + const isOwner = memberChar.user_id === campaign.currentUserId + return ( +
+ +
+ ) + } + const isCurrentUser = member.user_id === campaign.currentUserId + + if (member.accepted_at) { + // Accepted but no character - current user needs to add one + return ( +
+ + {isCurrentUser && ( + <> + + + + )} + {isDM && !isCurrentUser && ( + <> + + + + )} + +
+ ) + } + if (member.declined_at) { + // Declined invite (only visible to DMs) + return ( +
+ + {isDM && ( + + )} + +
+ ) + } + // Pending invite + return ( +
+ + {isDM && ( + + )} + +
+ ) + })} +
+ )} +
+
+ + {/* Section 2: NPCs - only show to players if there are visible NPCs */} + {(isDM || visibleNPCs.length > 0) && ( +
+
+
+

NPCs

+ {isDM && ( + + )} +
+ + {visibleNPCs.length === 0 ? ( +
+

No NPCs {isDM ? "added" : "revealed"} yet.

+
+ ) : ( +
+ {visibleNPCs.map((npc) => { + const isOwner = npc.added_by === campaign.currentUserId + return ( +
+ +
+ ) + })} +
+ )} +
+
+ )} + + {/* Section 3: DMs */} +
+
+
+

Dungeon Masters

+
+ {campaign.canChangeDMRole && ( + + )} + {isDM && ( + + )} +
+
+ + {dmMembers.length === 0 ? ( +
+

No dungeon masters yet.

+
+ ) : ( +
+ {dmMembers.map((dm) => { + const isCurrentUser = dm.user_id === campaign.currentUserId + + if (dm.accepted_at) { + // Accepted DM + return ( +
+ + {isCurrentUser && ( + + )} + +
+ ) + } + if (dm.declined_at) { + // Declined DM invite + return ( +
+ + {isDM && ( + + )} + +
+ ) + } + // Pending DM invite + return ( +
+ + {isDM && ( + + )} + +
+ ) + })} +
+ )} +
+
+ + {/* Section 4: Viewers */} +
+
+
+

Viewers

+ {campaign.canManageViewers && ( + + )} +
+ + {viewerMembers.length === 0 ? ( +
+

No viewers yet.

+
+ ) : ( +
+ {viewerMembers.map((viewer) => { + const isCurrentUser = viewer.user_id === campaign.currentUserId + + if (viewer.accepted_at) { + // Accepted viewer + return ( +
+ + {isCurrentUser && ( + + )} + {isDM && !isCurrentUser && ( + <> + + + + )} + +
+ ) + } + if (viewer.declined_at) { + // Declined viewer invite + return ( +
+ + {isDM && ( + + )} + +
+ ) + } + // Pending viewer invite + return ( +
+ + {isDM && ( + + )} + +
+ ) + })} +
+ )} +
+
+
+ + + + ) +} diff --git a/src/components/CampaignInviteForm.tsx b/src/components/CampaignInviteForm.tsx new file mode 100644 index 0000000..f9ef4ef --- /dev/null +++ b/src/components/CampaignInviteForm.tsx @@ -0,0 +1,101 @@ +import { ModalBody, ModalContent, ModalFooter } from "@src/components/ui/DetailModal" +import clsx from "clsx" + +export interface CampaignInviteFormProps { + campaignId: string + values?: Record + errors?: Record +} + +export const CampaignInviteForm = ({ + campaignId, + values = {}, + errors = {}, +}: CampaignInviteFormProps) => ( + +
+ + {/* General error */} + {errors.general && ( + + )} + +
+ + + {errors.email &&
{errors.email}
} + {!errors.email && ( +
An invitation email will be sent to this address.
+ )} +
+ + {errors._canReinvite && ( +
+ + +
+ )} + +
+ + + {errors.role &&
{errors.role}
} + {!errors.role && ( +
+ Players can add characters. DMs can manage the campaign. Viewers can only observe. +
+ )} +
+
+ + + + + +
+
+) diff --git a/src/components/CampaignNew.tsx b/src/components/CampaignNew.tsx new file mode 100644 index 0000000..0ae4f54 --- /dev/null +++ b/src/components/CampaignNew.tsx @@ -0,0 +1,86 @@ +import clsx from "clsx" + +export interface CampaignNewProps { + values?: Record + errors?: Record +} + +export const CampaignNew = ({ values = {}, errors = {} }: CampaignNewProps) => { + return ( +
+
+
+
+
+

Create New Campaign

+
+
+ + + {errors?.name &&
{errors.name}
} +
Choose a memorable name for your campaign
+
+ +
+ +