import { z } from "zod/v4"; import type { User } from "~/types/auth"; import type { Campaign, CampaignLog } from "~/types/campaign"; import { div, dom, icon, span, svg, text, type RedrawableHTML } from "#shared/dom.util"; import { button, loading, tabgroup, Toaster } from "#shared/components.util"; import { CharacterCompiler } from "#shared/character.util"; import { modal, tooltip } from "#shared/floating.util"; import markdown from "#shared/markdown.util"; import { preview } from "#shared/proses"; import { format } from "#shared/general.util"; import { Socket } from "#shared/websocket.util"; export const CampaignValidation = z.object({ id: z.number(), name: z.string().nonempty(), public_notes: z.string(), dm_notes: z.string(), settings: z.object(), }); class CharacterPrinter { compiler?: CharacterCompiler; container: RedrawableHTML = div('flex flex-col gap-2 px-1'); constructor(character: number, name: string) { this.container.replaceChildren(div('flex flex-row justify-between items-center', [ span('text-bold text-xl', name), loading('small')])); useRequestFetch()(`/api/character/${character}`).then((character) => { if(character) { this.compiler = new CharacterCompiler(character); const compiled = this.compiler.compiled; const armor = this.compiler.armor; this.container.replaceChildren(div('flex flex-row justify-between items-center', [ span('text-bold text-xl', compiled.name), div('flex flex-row gap-2 items-baseline', [ span('text-sm font-bold', 'PV'), span('text-lg', `${(compiled.health ?? 0) - (compiled.variables.health ?? 0)}/${compiled.health ?? 0}`) ]), ]), div('flex flex-row justify-between items-center', [ div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Armure'), span('text-lg', armor === undefined ? `-` : `${armor.current}/${armor.max}`) ]), div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Mana'), span('text-lg', `${(compiled.mana ?? 0) - (compiled.variables.mana ?? 0)}/${compiled.mana ?? 0}`) ]), div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Fatigue'), span('text-lg', `${compiled.variables.exhaustion ?? 0}`) ]), ])); } 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 = { statusDOM: RedrawableHTML; statusTooltip: Text; dom: RedrawableHTML; user: { id: number, username: string }; }; const logType: Record = { CHARACTER: ' a rencontré ', FIGHT: ' a affronté ', ITEM: ' a obtenu ', PLACE: ' est arrivé ', TEXT: ' ', } const activity = { online: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-green dark:bg-dark-green border-light-green dark:border-dark-green', text: 'En ligne' }, afk: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-yellow dark:bg-dark-yellow border-light-yellow dark:border-dark-yellow', text: 'Inactif' }, offline: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content border-dashed border-light-50 dark:border-dark-50 bg-light-0 dark:bg-dark-0', text: 'Hors ligne' }, } function defaultPlayerState(user: { id: number, username: string }): PlayerState { const statusTooltip = text(activity.offline.text), statusDOM = span(activity.offline.class); return { statusDOM, statusTooltip, dom: div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), user.username, 'bottom'), tooltip(statusDOM, statusTooltip, 'bottom') ]), user } } export class CampaignSheet { private user: ComputedRef; private campaign?: Campaign; container: RedrawableHTML = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6'); private dm!: PlayerState; private players!: Array; private characters!: Array; private characterList!: RedrawableHTML; private tab: string = 'campaign'; ws?: Socket; constructor(id: string, user: ComputedRef) { this.user = user; const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]); this.container.replaceChildren(load); useRequestFetch()(`/api/campaign/${id}`).then((campaign) => { if(campaign) { this.campaign = campaign; this.dm = defaultPlayerState(campaign.owner); this.players = campaign.members.map(e => defaultPlayerState(e.member)); this.characters = campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name)); this.ws = new Socket(`/ws/campaign/${id}`, true); this.ws.handleMessage<{ user: number, status: boolean }[]>('status', (users) => { users.forEach(user => { if(this.dm.user.id === user.user) { this.dm.statusTooltip.textContent = activity[user.status ? 'online' : 'offline'].text; this.dm.statusDOM.className = activity[user.status ? 'online' : 'offline'].class; } else { const player = this.players.find(e => e.user.id === user.user) if(player) { player.statusTooltip.textContent = activity[user.status ? 'online' : 'offline'].text; player.statusDOM.className = activity[user.status ? 'online' : 'offline'].class; } } }) }); this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('character', (character) => { if(character.action === 'ADD') { const printer = new CharacterPrinter(character.id, character.name); this.characters.push(printer); this.characterList.appendChild(printer.container); } else if(character.action === 'REMOVE') { const idx = this.characters.findIndex(e => e.compiler?.character.id !== character.id); if(idx !== -1) { this.characters[idx]!.container.remove(); this.characters.splice(idx, 1); } } }); this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('player', () => { this.render(); }); this.ws.handleMessage('hardsync', () => { this.render(); }); document.title = `d[any] - Campagne ${campaign.name}`; this.render(); load.remove(); } else throw new Error(); }).catch((e) => { console.error(e); this.container.replaceChildren(div('flex flex-col items-center justify-center flex-1 h-full gap-4', [ span('text-2xl font-bold tracking-wider', 'Campagne introuvable'), span(undefined, 'Cette campagne n\'existe pas ou est privé.'), div('flex flex-row gap-4 justify-center items-center', [ button(text('Mes campagnes'), () => useRouter().push({ name: 'campaign' }), 'px-2 py-1'), button(text('Créer une campagne'), () => useRouter().push({ name: 'campaign-create' }), 'px-2 py-1') ]) ])); }); } private logText(log: CampaignLog) { return `${log.target === 0 ? 'Le groupe' : this.players.find(e => e.user.id === log.target)?.user.username ?? 'Un personange'}${logType[log.type]}${log.details}`; } private render() { const campaign = this.campaign; if(!campaign) return; this.characterList = div('flex flex-col gap-2', this.characters.map(e => e.container)); this.container.replaceChildren(div('grid grid-cols-3 gap-2', [ div('flex flex-row gap-2 items-center py-2', [ this.dm.dom, div('border-l h-full w-0 border-light-40 dark:border-dark-40'), div('flex flex-row gap-1', this.players.map(e => e.dom)), ]), div('flex flex-1 flex-col items-center justify-center gap-2', [ span('text-2xl font-serif font-bold italic', campaign.name), span('italic text-light-70 dark:text-dark-70 text-sm', campaign.status === 'PREPARING' ? 'En préparation' : campaign.status === 'PLAYING' ? 'En jeu' : 'Archivé'), ]), div('flex flex-1 flex-col items-center justify-center', [ 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(`d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]), button(icon('radix-icons:clipboard', { width: 16, height: 16 }), () => {}, 'p-1'), ]), ]), ]), div('flex flex-row gap-4 flex-1 h-0', [ div('flex flex-col gap-2', [ div('flex flex-row items-center gap-4 w-[320px]', [ span('font-bold text-lg', 'Etat'), div('border-t border-light-40 dark:border-dark-40 border-dashed flex-1') ]), this.characterList, div('px-8 py-4 w-full flex', [ button([ icon('radix-icons:plus-circled', { width: 24, height: 24 }), span('text-sm', 'Ajouter un personnage'), ], () => { const load = loading('normal'); let characters: RedrawableHTML[] = []; const close = modal([ div('flex flex-col gap-4 items-center min-w-[480px] min-h-24', [ span('text-xl font-bold', 'Mes personnages'), load, ]), ], { closeWhenOutside: true, priority: true, class: { container: 'max-w-[560px]' } }).close; useRequestFetch()(`/api/character`).then((list) => { characters = list?.map(e => div('border border-light-40 dark:border-dark-40 p-2 flex flex-col w-[140px]', [ span('font-bold', e.name), span('', `Niveau ${e.level}`), button(text('Ajouter'), () => useRequestFetch()(`/api/character/${e.id}/campaign/${this.campaign!.id}`, { method: 'POST' }).then(() => this.ws!.send('character', { id: e.id, name: e.name, action: 'ADD', })).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(close)), ])) ?? []; }).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(() => { load.replaceWith(div('grid grid-cols-3 gap-2', characters.length > 0 ? characters : [span('text-light-60 dark:text-dark-60 text-sm italic', 'Vous n\'avez pas de personnage disponible')])); }); }, 'flex flex-col flex-1 gap-2 p-4 items-center justify-center text-light-60 dark:text-dark-60'), ]) ]), div('flex h-full border-l border-light-40 dark:border-dark-40'), div('flex flex-col', [ tabgroup([ { id: 'campaign', title: [ text('Campagne') ], content: () => [ markdown(campaign.public_notes, '', { tags: { a: preview }, class: 'px-2' }), ] }, { id: 'inventory', title: [ text('Inventaire') ], content: () => [ ] }, { id: 'logs', title: [ text('Logs') ], content: () => { let lastDate: Date = new Date(0); const logs = campaign.logs.flatMap(e => { const date = new Date(e.timestamp), arr = []; if(Math.floor(lastDate.getTime() / 86400000) < Math.floor(date.getTime() / 86400000)) { lastDate = date; arr.push(div('flex flex-row gap-2 items-center relative -left-2 mx-px', [ div('w-3 h-3 border-2 rounded-full bg-light-40 dark:bg-dark-40 border-light-0 dark:border-dark-0'), div('flex flex-row gap-2 items-center flex-1', [ div('flex-1 border-t border-light-40 dark:border-dark-40 border-dashed'), span('text-light-70 dark:text-dark-70 text-sm italic tracking-tight', format(date, 'dd MMMM yyyy')), div('flex-1 border-t border-light-40 dark:border-dark-40 border-dashed'), ]) ])) } arr.push(div('flex flex-row gap-2 items-center relative -left-2 mx-px group', [ div('w-3 h-3 border-2 rounded-full bg-light-40 dark:bg-dark-40 border-light-0 dark:border-dark-0'), div('flex flex-row items-center', [ svg('svg', { class: 'fill-light-40 dark:fill-dark-40', attributes: { width: "8", height: "12", viewBox: "0 0 6 9" } }, [svg('path', { attributes: { d: "M0 4.5L6 0L6 9L0 4.5Z" } })]), span('px-4 py-2 bg-light-25 dark:bg-dark-25 border border-light-40 dark:border-dark-40', this.logText(e)) ]), span('italic text-xs tracking-tight text-light-70 dark:text-dark-70 font-mono invisible group-hover:visible', format(new Date(e.timestamp), 'HH:mm:ss')), ])); return arr; }); return [ campaign.logs.length > 0 ? div('flex flex-row ps-12 py-4', [ div('border-l-2 border-light-40 dark:border-dark-40 relative before:absolute before:block before:border-[6px] before:border-b-[12px] before:-left-px before:-translate-x-1/2 before:border-transparent before:border-b-light-40 dark:before:border-b-dark-40 before:-top-3'), div('flex flex-col-reverse gap-8 py-4', logs), ]) : div('flex py-4 px-16', [ span('italic text-light-70 dark:text-darl-70', 'Aucune entrée pour le moment') ]), ] } }, { id: 'settings', title: [ text('Paramètres') ], content: () => [ ] }, { id: 'ressources', title: [ text('Ressources') ], content: () => [ ] } ], { focused: 'campaign', class: { container: 'max-w-[900px] w-[900px] h-full', content: 'overflow-auto', tabbar: 'gap-4' } }), ]) ])) } }