Add character selection using campaign visibility and player characters in campaign

This commit is contained in:
Clément Pons 2025-11-17 17:54:28 +01:00
parent d8480e7366
commit 3de2b0fe19
7 changed files with 98 additions and 72 deletions

View File

@ -1,6 +1,7 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite"; import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from '../db/schema'; import * as schema from '../db/schema';
import { eq, or, sql } from "drizzle-orm";
let instance: BunSQLiteDatabase<typeof schema> & { let instance: BunSQLiteDatabase<typeof schema> & {
$client: Database; $client: Database;

25
app/types/campaign.d.ts vendored Normal file
View File

@ -0,0 +1,25 @@
import type { User } from "./auth";
import type { Character, ItemState } from "./character";
import type { Serialize } from 'nitropack';
export type CampaignVariables = {
money: number;
inventory: ItemState[];
};
export type Campaign = {
id: number;
name: string;
link: string;
owner: { id: number, username: string };
members: Array<{ member: { id: number, username: string } }>;
characters: Array<Partial<{ character: { id: number, name: string, owner: number } }>>;
public_notes: string;
dm_notes: string;
logs: CampaignLog[];
} & CampaignVariables;
export type CampaignLog = {
from: number;
timestamp: Serialize<Date>;
type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'EVENT' | 'FIGHT' | 'TEXT';
details: string;
};

BIN
db.sqlite

Binary file not shown.

View File

@ -19,20 +19,17 @@ export default defineEventHandler(async (e) => {
const db = useDatabase(); const db = useDatabase();
const data = db.query.campaignTable.findFirst({ const data = db.query.campaignTable.findFirst({
columns: { owner: false },
with: { with: {
members: { with: { member: { columns: { username: true, id: true } } }, columns: { id: false, rights: false, user: false }, extras: { 'characters': sql`NULL`.as('characters') } }, members: { with: { member: { columns: { username: true, id: true } } }, columns: { id: false, user: false } },
characters: { with: { character: { columns: { id: true, name: true, owner: true } } } }, characters: { with: { character: { columns: { id: true, name: true, owner: true } } }, columns: { character: false } },
owner: { columns: { username: true, id: true } }, owner: { columns: { username: true, id: true } },
logs: { columns: { details: true, from: true, timestamp: true, type: true }, orderBy: ({ timestamp }) => timestamp } logs: { columns: { details: true, from: true, timestamp: true, type: true }, orderBy: ({ timestamp }) => timestamp },
}, },
where: ({ id: _id }) => eq(_id, parseInt(id, 10)), where: ({ id: _id }) => eq(_id, parseInt(id, 10)),
}).sync(); }).sync();
if(data && (data.owner.id === session.user.id || data.members.find(e => e.member?.id === session.user!.id))) if(data && (data.owner.id === session.user.id || data.members.find(e => e.member?.id === session.user!.id))) return data as Campaign;
{
data.members.forEach(e => e.characters = data.characters.filter(_e => _e.character?.owner === e.member?.id));
return data as Campaign;
}
else if(!data) return setResponseStatus(e, 404); else if(!data) return setResponseStatus(e, 404);
else return setResponseStatus(e, 403); else return setResponseStatus(e, 403);
}); });

View File

@ -1,7 +1,8 @@
import useDatabase from '~/composables/useDatabase'; import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema'; import { campaignCharactersTable, campaignMembersTable, campaignTable, characterAbilitiesTable, characterChoicesTable, characterLevelingTable, characterTable, characterTrainingTable, usersTable } from '~/db/schema';
import { group } from '#shared/general.util'; import { group } from '#shared/general.util';
import type { Character, CharacterVariables, Level, MainStat, TrainingLevel } from '~/types/character'; import type { Character, MainStat, TrainingLevel } from '~/types/character';
import { and, eq, exists, getTableColumns, isNotNull, or, sql } from 'drizzle-orm';
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id"); const id = getRouterParam(e, "id");
@ -21,6 +22,7 @@ export default defineEventHandler(async (e) => {
} }
const db = useDatabase(); const db = useDatabase();
const character = db.query.characterTable.findFirst({ const character = db.query.characterTable.findFirst({
with: { with: {
abilities: true, abilities: true,
@ -29,9 +31,23 @@ export default defineEventHandler(async (e) => {
choices: true, choices: true,
user: { user: {
columns: { username: true } columns: { username: true }
},
campaign: {
columns: { character: false, id: false, },
with: {
campaign: {
columns: { owner: true, },
with: {
members: { columns: { user: true } }
}
}
}
} }
}, },
where: (character, { eq, and }) => and(eq(character.id, parseInt(id, 10)), eq(characterTable.owner, session.user!.id)), where: and(eq(characterTable.id, parseInt(id, 10)), or(eq(characterTable.visibility, 'public'), eq(characterTable.owner, session.user!.id), exists(db.select({ id: sql`NULL` }).from(campaignCharactersTable)
.leftJoin(campaignTable, eq(campaignCharactersTable.id, campaignTable.id))
.leftJoin(campaignMembersTable, and(eq(campaignMembersTable.id, campaignTable.id), eq(campaignMembersTable.user, session.user.id)))
.where(and(eq(campaignCharactersTable.character, parseInt(id, 10)), or(eq(campaignTable.owner, session.user.id), isNotNull(campaignMembersTable.user))))))),
}).sync(); }).sync();
if(character !== undefined) if(character !== undefined)

View File

@ -5,6 +5,7 @@ import { div, dom, icon, span, text } from "#shared/dom.util";
import { button, loading } from "#shared/components.util"; import { button, loading } from "#shared/components.util";
import { CharacterCompiler } from "#shared/character.util"; import { CharacterCompiler } from "#shared/character.util";
import { tooltip } from "./floating.util"; import { tooltip } from "./floating.util";
import type { Character } from "~/types/character";
export const CampaignValidation = z.object({ export const CampaignValidation = z.object({
id: z.number(), id: z.number(),
@ -27,13 +28,33 @@ async function copyLink()
*/ */
class CharacterPrinter
{
private compiler?: CharacterCompiler;
container: HTMLElement = div('flex flex-col gap-2');
constructor(character: number, name: string)
{
this.container.replaceChildren(div('flex flex-row gap-1', [span(undefined, 'joue'), span('font-bold', name)]), loading('small'));
useRequestFetch()(`/api/character/${character}`).then((character) => {
if(character)
{
this.compiler = new CharacterCompiler(character);
this.container.replaceChildren(div('flex flex-row gap-1', [span(undefined, 'joue'), span('font-bold', name)]), );
}
else throw new Error();
}).catch((e) => {
console.error(e);
this.container.replaceChildren(span('text-sm italic text-light-red dark:text-dark-red', 'Données indisponible'));
})
}
}
type PlayerState = { type PlayerState = {
status: boolean; status: boolean;
statusDOM: HTMLElement; statusDOM: HTMLElement;
statusTooltip: Text; statusTooltip: Text;
user: User; user: { id: number, username: string };
}; };
function defaultPlayerState(user: User): PlayerState function defaultPlayerState(user: { id: number, username: string }): PlayerState
{ {
const statusTooltip = text('Absent'); const statusTooltip = text('Absent');
return { return {
@ -49,16 +70,21 @@ export class CampaignSheet
campaign?: Campaign; campaign?: Campaign;
container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center'); container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center');
dm!: PlayerState; dm!: PlayerState;
players!: Array<PlayerState & { characters: CharacterPrinter[] }>
constructor(id: string, user: ComputedRef<User | null>) constructor(id: string, user: ComputedRef<User | null>)
{ {
this.user = user; this.user = user;
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]); const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
this.container.replaceChildren(load); this.container.replaceChildren(load);
useRequestFetch()(`/api/campaign/${id}`).then(campaign => { useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
if(campaign) if(campaign)
{ {
this.campaign = campaign; this.campaign = campaign;
this.dm = defaultPlayerState(campaign.owner); this.dm = defaultPlayerState(campaign.owner);
this.players = campaign.members.map(e => ({
...defaultPlayerState(e.member),
characters: campaign.characters.filter(_e => _e.character?.owner === e.member.id).map(_e => new CharacterPrinter(_e.character!.id, _e.character!.name)),
}));
document.title = `d[any] - Campagne ${campaign.name}`; document.title = `d[any] - Campagne ${campaign.name}`;
this.render(); this.render();
@ -77,14 +103,19 @@ export class CampaignSheet
])); ]));
}); });
} }
render() private render()
{ {
const campaign = this.campaign;
if(!campaign)
return;
this.container.replaceChildren(div('flex flex-col w-full h-full items-center gap-4', [ this.container.replaceChildren(div('flex flex-col w-full h-full items-center gap-4', [
div('flex flex-row gap-8 items-center', [ div('flex flex-row gap-8 items-center', [
div('text-2xl font-semibold', [text(this.campaign.name)]), div('text-2xl font-semibold', [text(campaign.name)]),
div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [ div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [
dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`https://d-any.com/campaign/join/${ encodeURIComponent(this.campaign.link) }`) ]), dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`https://d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]),
button(icon('radix-icons:clipboard', { width: 16, height: 16 })), button(icon('radix-icons:clipboard', { width: 16, height: 16 }), () => {}, 'p-1'),
]) ])
]), ]),
div('flex flex-row gap-4 flex-1', [ div('flex flex-row gap-4 flex-1', [
@ -93,73 +124,29 @@ export class CampaignSheet
div('flex flex-col divide-y divide-light-25 dark:divide-dark-25', [ div('flex flex-col divide-y divide-light-25 dark:divide-dark-25', [
div('flex flex-col py-1 my-1', [ div('flex flex-col py-1 my-1', [
div('flex flex-row items-center justify-between', [ div('flex flex-row items-center justify-between', [
span(undefined, this.campaign.owner.username), span(undefined, this.dm.user.username),
this.dm.statusDOM, this.dm.statusDOM,
]) ])
]) ])
]), ]),
div('flex flex-row items-center gap-4', [ span('text-lg font-bold tracking-tight', 'Joueurs'), span('border-b border-dashed border-light-35 dark:border-dark-35 flex-1') ]), div('flex flex-row items-center gap-4', [ span('text-lg font-bold tracking-tight', 'Joueurs'), span('border-b border-dashed border-light-35 dark:border-dark-35 flex-1') ]),
div('flex flex-col divide-y divide-light-25 dark:divide-dark-25', div('flex flex-col divide-y divide-light-25 dark:divide-dark-25',
this.campaign.members.length > 0 ? this.campaign.members.map((player: any) => div('flex flex-col py-1 my-1', [ this.players.length > 0 ? this.players.map((player) => div('flex flex-col py-1 my-1', [
div('flex flex-row items-center justify-between', [ div('flex flex-row items-center justify-between', [
span(undefined, player.member.username), span(undefined, player.user.username),
undefined, player.statusDOM,
]), ]),
player.characters.length > 0 ? div('flex flex-col', player.characters && player.characters.length > 0 ? div('flex flex-col',
player.characters.map((character: any) => div('flex flex-row items-center justify-between', [ player.characters.map((character) => div('flex flex-row items-center justify-between', [
span(undefined, 'joue'), span('text-bold', character.name) character.container
])) ]))
) : span('text-sm italic text-light-70 dark:text-dark-70', 'Pas de personnages'), ) : span('text-sm italic text-light-70 dark:text-dark-70', 'Pas de personnages'),
])) : span('text-sm italic py-2 text-center', 'Invitez des joueurs via le lien'), ])) : [span('text-sm italic py-2 text-center', 'Invitez des joueurs via le lien')],
) )
]), ]),
div('border-l border-light-35 dark:border-dark-35'), div('border-l border-light-35 dark:border-dark-35'),
div('flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-[800px]') div('flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-[800px]')
]) ])
])); ]));
/*
<div class="flex flex-col w-full h-full items-center gap-4" v-else-if="campaign && status === 'success'">
<div class="flex flex-row gap-4 flex-1">
<div class="flex flex-col">
<div class="flex flex-row items-center gap-4"><span class="text-lg font-bold tracking-tight flex-1">Maitre de jeu</span><span class="border-b border-dashed border-light-35 dark:border-dark-35 flex-1"></span></div>
<div class="flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-64">
<div class="flex flex-col py-1 my-1">
<div class="flex flex-row items-center justify-between">
<span>{{ campaign.owner.username }}</span>
<Tooltip message="Absent" side="right" :delay="0"><span class="rounded-full w-3 h-3 block border-light-50 dark:border-dark-50 border-2 border-dashed"></span></Tooltip>
</div>
</div>
</div>
<div class="flex flex-row items-center gap-4"><span class="text-lg font-bold tracking-tight">Joueurs</span><span class="border-b border-dashed border-light-35 dark:border-dark-35 w-full"></span></div>
<div class="flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-64">
<template v-if="campaign.members.length > 0">
<div v-for="player of campaign.members" class="flex flex-col py-1 my-1">
<div class="flex flex-row items-center justify-between">
<span>{{ player.member?.username }}</span>
<Tooltip message="Absent" side="right" :delay="0"><span class="rounded-full w-3 h-3 block border-light-50 dark:border-dark-50 border-2 border-dashed"></span></Tooltip>
</div>
<template v-if="player.characters.length > 0">
<div class="flex flex-col">
<div class="flex flex-row items-center justify-between" v-for="character of player.characters">
<span>joue {{ character.character.name }}</span>
</div>
</div>
</template>
<template v-else>
<span class="text-sm italic text-light-70 dark:text-dark-70">Sans personnage</span>
</template>
</div>
</template>
<template v-else>
<span class="text-sm italic py-2 text-center">Invitez des joueurs via le lien d'invitation</span>
</template>
</div>
</div>
<div class="border-l border-light-35 dark:border-dark-35"></div>
<div class="flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-[800px]">
</div>
</div>
</div>
*/
} }
} }

View File

@ -1359,7 +1359,7 @@ export class CharacterSheet
readonly: dom("span", { readonly: dom("span", {
class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35", class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35",
text: `${character.health - character.variables.health}`, text: `${character.health - character.variables.health}`,
listeners: { click: () => health.readonly.replaceWith(health.edit) }, listeners: { click: () => { health.readonly.replaceWith(health.edit); health.edit.select(); health.edit.focus(); } },
}), }),
edit: input('text', { defaultValue: (character.health - character.variables.health).toString(), input: (v) => { edit: input('text', { defaultValue: (character.health - character.variables.health).toString(), input: (v) => {
return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10)); return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10));
@ -1370,7 +1370,7 @@ export class CharacterSheet
readonly: dom("span", { readonly: dom("span", {
class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35", class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35",
text: `${character.mana - character.variables.mana}`, text: `${character.mana - character.variables.mana}`,
listeners: { click: () => mana.readonly.replaceWith(mana.edit) }, listeners: { click: () => { mana.readonly.replaceWith(mana.edit); mana.edit.select(); mana.edit.focus(); } },
}), }),
edit: input('text', { defaultValue: (character.mana - character.variables.mana).toString(), input: (v) => { edit: input('text', { defaultValue: (character.mana - character.variables.mana).toString(), input: (v) => {
return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10)); return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10));