feat: add DM/player role separation — atmosphere, invite, and edit controls gated by role

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 00:44:27 -04:00
parent e2548ad660
commit 47215b48f1
4 changed files with 43 additions and 11 deletions

View file

@ -20,6 +20,7 @@ interface CharacterCardProps {
onHpChange: (characterId: number, hp: number) => void;
onUpdate: (characterId: number, data: Partial<Character>) => void;
onClick: (characterId: number) => void;
canEdit?: boolean;
}
export default function CharacterCard({
@ -27,6 +28,7 @@ export default function CharacterCard({
onHpChange,
onUpdate,
onClick,
canEdit = true,
}: CharacterCardProps) {
return (
<div

View file

@ -27,6 +27,7 @@ interface CharacterDetailProps {
onRemoveTalent: (characterId: number, talentId: number) => void;
onDelete: (id: number) => void;
onClose: () => void;
canEdit?: boolean;
}
export default function CharacterDetail({
@ -42,6 +43,7 @@ export default function CharacterDetail({
onRemoveTalent,
onDelete,
onClose,
canEdit = true,
}: CharacterDetailProps) {
const [mode, setMode] = useState<"view" | "edit">("view");
@ -49,12 +51,14 @@ export default function CharacterDetail({
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.topBar}>
<button
className={`${styles.editBtn} ${mode === "edit" ? styles.active : ""}`}
onClick={() => setMode(mode === "view" ? "edit" : "view")}
>
{mode === "view" ? "Edit" : "Done"}
</button>
{canEdit && (
<button
className={`${styles.editBtn} ${mode === "edit" ? styles.active : ""}`}
onClick={() => setMode(mode === "view" ? "edit" : "view")}
>
{mode === "view" ? "Edit" : "Done"}
</button>
)}
<button className={styles.closeBtn} onClick={onClose}>
</button>

View file

@ -13,7 +13,10 @@ import {
addTalent,
removeTalent,
getRolls,
getMyCampaignRole,
generateInvite,
} from "../api";
import { useAuth } from "../context/AuthContext";
import type { Character, Gear, Talent, GameItem, RollResult } from "../types";
import CharacterCard from "../components/CharacterCard";
import CharacterDetail from "../components/CharacterDetail";
@ -34,6 +37,8 @@ const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
export default function CampaignView() {
const { id } = useParams<{ id: string }>();
const campaignId = Number(id);
const { user } = useAuth();
const [role, setRole] = useState<"dm" | "player" | null>(null);
const [characters, setCharacters] = useState<Character[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showCreate, setShowCreate] = useState(false);
@ -65,6 +70,7 @@ export default function CampaignView() {
useEffect(() => {
getCharacters(campaignId).then(setCharacters);
getRolls(campaignId).then(setRolls);
getMyCampaignRole(campaignId).then((r) => setRole(r.role)).catch(() => {});
getCampaigns().then((camps) => {
const c = camps.find((x) => x.id === campaignId);
if (c) setCampaignName(c.name);
@ -352,6 +358,16 @@ export default function CampaignView() {
await removeTalent(characterId, talentId);
}
async function handleInvite() {
try {
const { url } = await generateInvite(campaignId);
await navigator.clipboard.writeText(url);
alert("Invite link copied to clipboard!");
} catch {
alert("Failed to generate invite link");
}
}
const selectedCharacter = characters.find((c) => c.id === selectedId) ?? null;
return (
@ -365,10 +381,17 @@ export default function CampaignView() {
{campaignName || "Campaign"}
</span>
<div className={styles.headerBtns}>
<AtmospherePanel
atmosphere={atmosphere}
onAtmosphereChange={handleAtmosphereChange}
/>
{role === "dm" && (
<AtmospherePanel
atmosphere={atmosphere}
onAtmosphereChange={handleAtmosphereChange}
/>
)}
{role === "dm" && (
<button className={styles.addBtn} onClick={handleInvite}>
Invite Player
</button>
)}
<button
className={styles.addBtn}
onClick={() => setShowCreate(true)}
@ -391,6 +414,7 @@ export default function CampaignView() {
onHpChange={handleHpChange}
onUpdate={handleUpdate}
onClick={setSelectedId}
canEdit={role === "dm" || char.user_id === user?.userId}
/>
))}
</div>
@ -409,6 +433,7 @@ export default function CampaignView() {
onRemoveTalent={handleRemoveTalent}
onDelete={handleDelete}
onClose={() => setSelectedId(null)}
canEdit={role === "dm" || selectedCharacter.user_id === user?.userId}
/>
)}

View file

@ -1,7 +1,7 @@
export interface Campaign {
id: number;
name: string;
created_by: string;
role?: string;
created_at: string;
}
@ -33,6 +33,7 @@ export interface Talent {
export interface Character {
id: number;
campaign_id: number;
user_id?: number | null;
created_by: string;
name: string;
class: string;