obsidian-visualiser/shared/campaign.util.ts

220 lines
12 KiB
TypeScript

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 } from "#shared/dom.util";
import { button, loading, tabgroup } from "#shared/components.util";
import { CharacterCompiler } from "#shared/character.util";
import { 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: HTMLElement = 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;
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}`) ])
]));
}
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: HTMLElement;
statusTooltip: Text;
dom: HTMLElement;
user: { id: number, username: string };
};
const logType: Record<CampaignLog['type'], string> = {
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<User | null>;
private campaign?: Campaign;
container: HTMLElement = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6');
private dm!: PlayerState;
private players!: Array<PlayerState>;
private characters!: Array<CharacterPrinter>;
ws?: Socket;
constructor(id: string, user: ComputedRef<User | null>)
{
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 }[]>('user', (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;
}
}
})
});
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 ?? 'Le groupe'}${logType[log.type]}${log.details}`;
}
private render()
{
const campaign = this.campaign;
if(!campaign)
return;
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', [
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.characters.map(e => e.container),
]),
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 } }),
] },
{ 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', 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', [
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: "6", height: "9", viewBox: "0 0 6 9" } }, [svg('path', { attributes: { d: "M0 4.5L6 -4.15L6 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', 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]' } }),
])
]))
}
}