From 61d2d144b7f5aa887c1fd5c70e41cb24f7f88eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pons?= Date: Tue, 30 Sep 2025 17:15:49 +0200 Subject: [PATCH] Spell UI, variables saving and mail server fixes (finally working in prod !!!) --- db.sqlite | Bin 761856 -> 761856 bytes db.sqlite-shm | Bin 32768 -> 32768 bytes db.sqlite-wal | Bin 8272 -> 0 bytes db/schema.ts | 2 +- server/api/admin/jobs/[id].post.ts | 17 +- server/api/character/[id]/values.get.ts | 35 -- .../{values.post.ts => variables.post.ts} | 13 +- server/components/mail/base.vue | 6 +- server/tasks/mail.ts | 97 +++--- shared/character-config.json | 16 +- shared/character.util.ts | 300 +++++++++++------- shared/components.util.ts | 34 +- shared/dom.util.ts | 6 +- shared/floating.util.ts | 9 +- shared/proses.ts | 68 ++-- types/character.d.ts | 2 +- 16 files changed, 336 insertions(+), 269 deletions(-) delete mode 100644 server/api/character/[id]/values.get.ts rename server/api/character/[id]/{values.post.ts => variables.post.ts} (74%) diff --git a/db.sqlite b/db.sqlite index 827b942840d0381ee4ff23b27394b0c04119c9aa..e7966d7a1dc347b7e88df2b2ac9023a7a01a40c3 100644 GIT binary patch delta 660 zcmZ9KL2J}N6vwk$wXNM+5Co;5l5^S3B$Lg=lU@ZsfT*-dHj`u~lSwj@O(r0_6`|q> zaHU@S0)i;mYtQ1vv-nY3oV|z$@5dXMA3S*fw>%#&&&QuH4mW;%fBNO<;@-NyT7Fv& z1|N6!urVCH({mPGRB&SSyvNamBb|oO`yPAy%bH%0vA>PnDfwSv4U?1~hWHn%DnAq0J4BQF3Va!H#@tlv(V>XO^~lgr6av!S`7xvIIQ b**v?PY<-vvwogtEw>0a1{p|E`2S5A+f12Kn delta 211 zcmZoTpx1CfZ^Pes=3A@IZc6wo52BkIwly#^F5u?-!N9~k(G~! zL6p;%k%2*)p;3MM13jjM>Aw0*GCb7`l0acbUa;^|9VRcsMlD_*gkZK$X-Z~EMyi>a zQLdq7p{b=oc3vX~vI_O-Ky69WqxG4%xOJ3L3rjN#a#Qn4+JEXZ0WmWWvj8zG5VHX> c`}Uvu91HXrIofB%gD@u$b8Vj$&zDz^o@1e@vP+ni9o2#n7F1^DuXok5?9lhVApsVg7u+ zxp*|+{GaFfuB|C+ADuua&T-ZG3oy)t}AAh^#WX zf<-R@Lk$pwwLq>&r!7S))NY@+!|G$4TPi2DKrq#FZd(Xqwgwa?C$sRv3TihWoMH9e z*%-K79|(aZ0Wfl$H`4Mz^zh4IgnOq@D5EKqwBmT3aA850Elj?nhfERJ(NaC9B_KcU)!OjzCDs z1WnBl6Ge~|T!>a{LfLi%I78^`Ea;%>-S{=yK7ZY-I;I9R!mdu+m`&B6%|H zZuy4QOpp&x)`hnYf47Pp5<+l5d^TOKDJzog%BlJ4!Kq7cQ>f8Y5Q#lOJ_z7t%8+9c z*(FXyx;?(XhjZ`b_rH6AFZUh#{P5Zz=co3U7F)w&Yw;^j`{)EZfliI)OKlz{<8A!#K{?Pkg$&EuVUSzO-v;2DktK diff --git a/db/schema.ts b/db/schema.ts index 4a24196..2640c65 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -55,7 +55,7 @@ export const characterTable = table("character", { owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), people: text().notNull(), level: int().notNull().default(1), - variables: text({ mode: 'json' }).notNull().default('{"health": 0,"mana": 0,"spells": [],"equipment": [],"exhaustion": 0,"sickness": []}'), + variables: text({ mode: 'json' }).notNull().default('{"health": 0,"mana": 0,"spells": [],"equipment": [],"exhaustion": 0,"sickness": [],"poisons": []}'), aspect: int(), notes: text(), diff --git a/server/api/admin/jobs/[id].post.ts b/server/api/admin/jobs/[id].post.ts index 2f21ded..3cc375b 100644 --- a/server/api/admin/jobs/[id].post.ts +++ b/server/api/admin/jobs/[id].post.ts @@ -4,7 +4,11 @@ declare module 'nitropack' { interface TaskPayload { - type: string + type: string; + } + interface TaskResult + { + error?: Error | string; } } @@ -17,7 +21,7 @@ export default defineEventHandler(async (e) => { return; } const id = getRouterParam(e, 'id'); - const payload: Record = await readBody(e); + const body: Record = await readBody(e); if(!id) { @@ -25,8 +29,11 @@ export default defineEventHandler(async (e) => { return; } - payload.type = id; - payload.data = JSON.parse(payload.data); + body.data = JSON.parse(body.data); + const payload = { + type: id, + data: body, + } const result = await runTask(id, { payload: payload @@ -36,7 +43,7 @@ export default defineEventHandler(async (e) => { { setResponseStatus(e, 500); - if(result.error && (result.error as Error).message) + if(result.error && result.error.message) throw result.error; else if(result.error) throw new Error(result.error); diff --git a/server/api/character/[id]/values.get.ts b/server/api/character/[id]/values.get.ts deleted file mode 100644 index 41d58b5..0000000 --- a/server/api/character/[id]/values.get.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { and, eq, sql } from 'drizzle-orm'; -import useDatabase from '~/composables/useDatabase'; -import { characterTable } from '~/db/schema'; -import type { CharacterVariables } from '~/types/character'; - -export default defineEventHandler(async (e) => { - const id = getRouterParam(e, "id"); - - if(!id) - { - setResponseStatus(e, 400); - return; - } - - const session = await getUserSession(e); - if(!session.user) - { - setResponseStatus(e, 401); - return; - } - - const db = useDatabase(); - const character = db.select({ - health: characterTable.health, - mana: characterTable.mana, - }).from(characterTable).where(and(eq(characterTable.id, parseInt(id, 10)), eq(characterTable.owner, session.user.id))).get(); - - if(character !== undefined) - { - return character as CharacterVariables; - } - - setResponseStatus(e, 404); - return; -}); \ No newline at end of file diff --git a/server/api/character/[id]/values.post.ts b/server/api/character/[id]/variables.post.ts similarity index 74% rename from server/api/character/[id]/values.post.ts rename to server/api/character/[id]/variables.post.ts index 35c7dc2..137a36c 100644 --- a/server/api/character/[id]/values.post.ts +++ b/server/api/character/[id]/variables.post.ts @@ -1,7 +1,8 @@ import { eq } from 'drizzle-orm'; import useDatabase from '~/composables/useDatabase'; import { characterTable } from '~/db/schema'; -import type { CharacterValues } from '~/types/character'; +import { CharacterVariablesValidation } from '~/shared/character.util'; +import type { CharacterVariables } from '~/types/character'; export default defineEventHandler(async (e) => { const id = getRouterParam(e, "id"); @@ -11,11 +12,12 @@ export default defineEventHandler(async (e) => { return; } - const body = await readBody(e) as CharacterValues; - if(!body) + const body = await readValidatedBody(e, CharacterVariablesValidation.safeParse); + if(!body.success) { + console.error(body.error); setResponseStatus(e, 400); - return; + throw body.error; } const db = useDatabase(); @@ -35,8 +37,7 @@ export default defineEventHandler(async (e) => { } db.update(characterTable).set({ - health: body.health, - mana: body.mana, + variables: body.data }).where(eq(characterTable.id, parseInt(id, 10))).run(); setResponseStatus(e, 200); diff --git a/server/components/mail/base.vue b/server/components/mail/base.vue index 0207356..f0067a5 100644 --- a/server/components/mail/base.vue +++ b/server/components/mail/base.vue @@ -1,9 +1,9 @@ \ No newline at end of file diff --git a/server/tasks/mail.ts b/server/tasks/mail.ts index 1a4e32d..754d661 100644 --- a/server/tasks/mail.ts +++ b/server/tasks/mail.ts @@ -17,10 +17,9 @@ export const templates: Record = { import type Mail from 'nodemailer/lib/mailer'; interface MailPayload { - type: 'mail' - to: string[] - template: string - data: Record + to: string[]; + template: string; + data: Record; } const transport = nodemailer.createTransport({ @@ -55,51 +54,59 @@ if(process.env.NODE_ENV === 'production') }); } -export default async function(e: TaskEvent) { - try { - if(e.payload.type !== 'mail') - { - throw new Error(`Données inconnues`); +export default defineTask({ + meta: { + name: 'mail', + description: '' + }, + run: async ({ payload, context }) => { + try { + if(payload.type !== 'mail') + { + throw new Error(`Données inconnues`); + } + + const mailPayload = payload.data as MailPayload; + const template = templates[mailPayload.template]; + + console.log(mailPayload); + + if(!template) + { + throw new Error(`Modèle de mail ${mailPayload.template} inconnu`); + } + + console.time('Generating HTML'); + const mail: Mail.Options = { + from: 'd[any] - Ne pas répondre ', + to: mailPayload.to, + html: await render(template.component, mailPayload.data), + subject: template.subject, + textEncoding: 'quoted-printable', + }; + console.timeEnd('Generating HTML'); + + if(mail.html === '') + return { result: false, error: new Error("Invalid content") }; + + console.time('Sending Mail'); + const status = await transport.sendMail(mail); + console.timeEnd('Sending Mail'); + + if(status.rejected.length > 0) + { + return { result: false, error: status.response, details: status.rejectedErrors }; + } + + return { result: true }; } - - const payload = e.payload as MailPayload; - const template = templates[payload.template]; - - if(!template) + catch(e) { - throw new Error(`Modèle de mail ${payload.template} inconnu`); + console.error(e); + return { result: false, error: e }; } - - console.time('Generating HTML'); - const mail: Mail.Options = { - from: 'd[any] - Ne pas répondre ', - to: payload.to, - html: await render(template.component, payload.data), - subject: template.subject, - textEncoding: 'quoted-printable', - }; - console.timeEnd('Generating HTML'); - - if(mail.html === '') - return { result: false, error: new Error("Invalid content") }; - - console.time('Sending Mail'); - const status = await transport.sendMail(mail); - console.timeEnd('Sending Mail'); - - if(status.rejected.length > 0) - { - return { result: false, error: status.response, details: status.rejectedErrors }; - } - - return { result: true }; } - catch(e) - { - console.error(e); - return { result: false, error: e }; - } -} +}) async function render(component: any, data: Record): Promise { diff --git a/shared/character-config.json b/shared/character-config.json index ccbf1df..8fdbb6a 100644 --- a/shared/character-config.json +++ b/shared/character-config.json @@ -558,12 +558,18 @@ } }, "lists": { - "sickness": [ - { - "id": "", - "name": "Pourriture mortelle" + "sickness": { + "id": "sickness", + "config": { + + }, + "values": { + "rotted": { + "id": "rotted", + "name": "Pourriture mortelle" + } } - ] + } }, "peoples": { "e662m19q590kn4dowvssowi1qf8ia7sk": { diff --git a/shared/character.util.ts b/shared/character.util.ts index fd6d404..90a2e87 100644 --- a/shared/character.util.ts +++ b/shared/character.util.ts @@ -1,9 +1,9 @@ -import type { Ability, Alignment, Character, CharacterConfig, CharacterVariables, CompiledCharacter, FeatureItem, Level, MainStat, Resistance, SpellElement, SpellType, TrainingLevel } from "~/types/character"; +import type { Ability, Alignment, Character, CharacterConfig, CharacterVariables, CompiledCharacter, FeatureItem, Level, MainStat, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel } from "~/types/character"; import { z } from "zod/v4"; import characterConfig from '#shared/character-config.json'; import proses, { preview } from "#shared/proses"; import { button, buttongroup, foldable, input, loading, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util"; -import { div, dom, icon, text } from "#shared/dom.util"; +import { div, dom, icon, span, text } from "#shared/dom.util"; import { followermenu, fullblocker, tooltip } from "#shared/floating.util"; import { clamp } from "#shared/general.util"; import markdown from "#shared/markdown.util"; @@ -40,6 +40,7 @@ export const defaultCharacter: Character = { items: [], exhaustion: 0, sickness: [], + poisons: [], }, owner: -1, @@ -113,7 +114,10 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c magicelement: 0, magicinstinct: 0, }, - bonus: {}, + bonus: { + abilities: {}, + defense: {}, + }, resistance: {}, initiative: 0, capacity: 0, @@ -125,7 +129,7 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c spells: [], }, aspect: "", - notes: character.notes ?? "", + notes: Object.assign({ public: '', private: '' }, character.notes), }); export const mainStatTexts: Record = { @@ -158,6 +162,10 @@ export const elementTexts: Record light: { class: 'text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow', text: 'Lumière' }, psyche: { class: 'text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-purple bg-light-purple dark:bg-dark-purple', text: 'Psy' }, }; +export const elementDom = (element: SpellElement) => dom("span", { + class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px`, elementTexts[element].class], + text: elementTexts[element].text +}); export const alignmentTexts: Record = { 'loyal_good': 'Loyal bon', @@ -205,6 +213,22 @@ export const resistanceTexts: Record = { 'instinct': 'Sorts d\'instinct', }; +export const CharacterVariablesValidation = z.object({ + health: z.number(), + mana: z.number(), + exhaustion: z.number(), + + sickness: z.array(z.object({ + id: z.string(), + state: z.number().min(1).max(7).or(z.literal(true)), + })), + poisons: z.array(z.object({ + id: z.string(), + state: z.number().min(1).max(7).or(z.literal(true)), + })), + spells: z.array(z.string()), + equipment: z.array(z.string()), +}); export const CharacterValidation = z.object({ id: z.number(), name: z.string(), @@ -216,18 +240,7 @@ export const CharacterValidation = z.object({ leveling: z.record(z.enum(LEVELS.map(String)), z.number().optional()), abilities: z.record(z.enum(ABILITIES), z.number().optional()), choices: z.record(z.string(), z.array(z.number())), - variables: z.object({ - health: z.number(), - mana: z.number(), - exhaustion: z.number(), - - sickness: z.array(z.object({ - id: z.string(), - state: z.number().min(1).max(7).or(z.literal(true)), - })), - spells: z.array(z.string()), - equipment: z.array(z.string()), - }), + variables: CharacterVariablesValidation, owner: z.number(), username: z.string().optional(), visibility: z.enum(["public", "private"]), @@ -241,6 +254,7 @@ export class CharacterCompiler protected _character!: Character; protected _result!: CompiledCharacter; protected _buffer: Record = {}; + private _variableDirty: boolean = false; constructor(character: Character) { @@ -299,6 +313,20 @@ export class CharacterCompiler { this._character.variables[prop] = value; this._result.variables[prop] = value; + this._variableDirty = true; + } + saveVariables() + { + if(this._variableDirty) + { + this._variableDirty = false; + useRequestFetch()(`/api/character/${this.character.id}/variables`, { + method: 'POST', + body: this._character.variables, + }).then(() => {}).catch(() => { + Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true }); + }) + } } protected add(feature?: string) { @@ -1142,10 +1170,13 @@ class AspectPicker extends BuilderTab export class CharacterSheet { + user: ComputedRef; character?: CharacterCompiler; container: HTMLElement = div(); + tabs?: HTMLDivElement & { refresh: () => void }; 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/character/${id}`).then(character => { @@ -1159,9 +1190,16 @@ export class CharacterSheet this.render(); } else - { - //ERROR - } + throw new Error(); + }).catch(() => { + this.container.replaceChildren(div('flex flex-col items-center justify-center flex-1 h-full gap-4', [ + span('text-2xl font-bold tracking-wider', 'Personnage introuvable'), + span(undefined, 'Ce personnage n\'existe pas ou est privé.'), + div('flex flex-row gap-4 justify-center items-center', [ + button(text('Personnages publics'), () => useRouter().push({ name: 'character-list' }), 'px-2 py-1'), + button(text('Créer un personange'), () => useRouter().push({ name: 'character-id-edit', params: { id: 'new' } }), 'px-2 py-1') + ]) + ])) }); } render() @@ -1170,7 +1208,22 @@ export class CharacterSheet return; const character = this.character.compiled; - console.log(character); + + this.tabs = tabgroup([ + { id: 'actions', title: [ text('Actions') ], content: () => this.actionsTab(character) }, + + { id: 'abilities', title: [ text('Aptitudes') ], content: () => this.abilitiesTab(character) }, + + { id: 'spells', title: [ text('Sorts') ], content: () => this.spellTab(character) }, + + { id: 'inventory', title: [ text('Inventaire') ], content: () => [ + + ] }, + + { id: 'notes', title: [ text('Notes') ], content: () => [ + + ] }, + ], { focused: 'abilities', class: { container: 'flex-1 gap-4 px-4 w-[960px]' } }); this.container.replaceChildren(div('flex flex-col justify-center gap-1', [ div("flex flex-row gap-4 justify-between", [ div(), @@ -1215,10 +1268,9 @@ export class CharacterSheet ]), div("self-center", [ - /* user && user.id === character.owner ? - button(icon("radix-icons:pencil-2"), () => { - }, "icon") - : div() */ + this.user.value && this.user.value.id === character.owner ? + button(icon("radix-icons:pencil-2"), () => useRouter().push({ name: 'character-id-edit', params: { id: this.character?.character.id } }), "p-1") + : div() ]) ]), @@ -1299,7 +1351,7 @@ export class CharacterSheet div("flex flex-col gap-4 py-1 w-80", [ div("flex flex-col py-1 gap-4", [ div("flex flex-row items-center justify-center gap-4", [ - div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-semibold', text: "Compétences" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/l\'entrainement/competences', class: 'h-4' }) ]), + div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-semibold', text: "Compétences" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/l\'entrainement/competences', size: 'small', class: 'h-4' }) ]), div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50") ]), @@ -1314,107 +1366,97 @@ export class CharacterSheet div("flex flex-row items-center justify-center gap-4", [ - div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-semibold', text: "Maitrises" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/l\'entrainement/competences', class: 'h-4' }) ]), + div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-semibold', text: "Maitrises" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/l\'entrainement/competences', size: 'small', class: 'h-4' }) ]), div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50") ]), character.mastery.strength + character.mastery.dexterity > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ - character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme légère') ], { href: 'regles/annexes/equipement#Les armes légères' }) : undefined, - character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme de jet') ], { href: 'regles/annexes/equipement#Les armes de jet' }) : undefined, - character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme naturelle') ], { href: 'regles/annexes/equipement#Les armes naturelles' }) : undefined, - character.mastery.strength > 1 ? proses('a', preview, [ text('Arme standard') ], { href: 'regles/annexes/equipement#Les armes' }) : undefined, - character.mastery.strength > 1 ? proses('a', preview, [ text('Arme improvisée') ], { href: 'regles/annexes/equipement#Les armes improvisées' }) : undefined, - character.mastery.strength > 2 ? proses('a', preview, [ text('Arme lourde') ], { href: 'regles/annexes/equipement#Les armes lourdes' }) : undefined, - character.mastery.strength > 3 ? proses('a', preview, [ text('Arme à deux mains') ], { href: 'regles/annexes/equipement#Les armes à deux mains' }) : undefined, - character.mastery.dexterity > 0 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme maniable') ], { href: 'regles/annexes/equipement#Les armes maniables' }) : undefined, - character.mastery.dexterity > 1 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme à projectiles') ], { href: 'regles/annexes/equipement#Les armes à projectiles' }) : undefined, - character.mastery.dexterity > 1 && character.mastery.strength > 2 ? proses('a', preview, [ text('Arme longue') ], { href: 'regles/annexes/equipement#Les armes longues' }) : undefined, - character.mastery.shield > 0 ? proses('a', preview, [ text('Bouclier') ], { href: 'regles/annexes/equipement#Les boucliers' }) : undefined, - character.mastery.shield > 0 && character.mastery.strength > 3 ? proses('a', preview, [ text('Bouclier à deux mains') ], { href: 'regles/annexes/equipement#Les boucliers à deux mains' }) : undefined, + character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme légère') ], { href: 'regles/annexes/equipement#Les armes légères', size: 'small' }) : undefined, + character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme de jet') ], { href: 'regles/annexes/equipement#Les armes de jet', size: 'small' }) : undefined, + character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme naturelle') ], { href: 'regles/annexes/equipement#Les armes naturelles', size: 'small' }) : undefined, + character.mastery.strength > 1 ? proses('a', preview, [ text('Arme standard') ], { href: 'regles/annexes/equipement#Les armes', size: 'small' }) : undefined, + character.mastery.strength > 1 ? proses('a', preview, [ text('Arme improvisée') ], { href: 'regles/annexes/equipement#Les armes improvisées', size: 'small' }) : undefined, + character.mastery.strength > 2 ? proses('a', preview, [ text('Arme lourde') ], { href: 'regles/annexes/equipement#Les armes lourdes', size: 'small' }) : undefined, + character.mastery.strength > 3 ? proses('a', preview, [ text('Arme à deux mains') ], { href: 'regles/annexes/equipement#Les armes à deux mains', size: 'small' }) : undefined, + character.mastery.dexterity > 0 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme maniable') ], { href: 'regles/annexes/equipement#Les armes maniables', size: 'small' }) : undefined, + character.mastery.dexterity > 1 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme à projectiles') ], { href: 'regles/annexes/equipement#Les armes à projectiles', size: 'small' }) : undefined, + character.mastery.dexterity > 1 && character.mastery.strength > 2 ? proses('a', preview, [ text('Arme longue') ], { href: 'regles/annexes/equipement#Les armes longues', size: 'small' }) : undefined, + character.mastery.shield > 0 ? proses('a', preview, [ text('Bouclier') ], { href: 'regles/annexes/equipement#Les boucliers', size: 'small' }) : undefined, + character.mastery.shield > 0 && character.mastery.strength > 3 ? proses('a', preview, [ text('Bouclier à deux mains') ], { href: 'regles/annexes/equipement#Les boucliers à deux mains', size: 'small' }) : undefined, ]) : undefined, character.mastery.armor > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ - character.mastery.armor > 0 ? proses('a', preview, [ text('Armure légère') ], { href: 'regles/annexes/equipement#Les armures légères' }) : undefined, - character.mastery.armor > 1 ? proses('a', preview, [ text('Armure standard') ], { href: 'regles/annexes/equipement#Les armures' }) : undefined, - character.mastery.armor > 2 ? proses('a', preview, [ text('Armure lourde') ], { href: 'regles/annexes/equipement#Les armures lourdes' }) : undefined, + character.mastery.armor > 0 ? proses('a', preview, [ text('Armure légère') ], { href: 'regles/annexes/equipement#Les armures légères', size: 'small' }) : undefined, + character.mastery.armor > 1 ? proses('a', preview, [ text('Armure standard') ], { href: 'regles/annexes/equipement#Les armures', size: 'small' }) : undefined, + character.mastery.armor > 2 ? proses('a', preview, [ text('Armure lourde') ], { href: 'regles/annexes/equipement#Les armures lourdes', size: 'small' }) : undefined, ]) : undefined, div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ - div('flex flex-row items-center gap-2', [ text('Précision'), dom('span', { text: character.spellranks.precision.toString(), class: 'font-bold' }) ]), - div('flex flex-row items-center gap-2', [ text('Savoir'), dom('span', { text: character.spellranks.knowledge.toString(), class: 'font-bold' }) ]), - div('flex flex-row items-center gap-2', [ text('Instinct'), dom('span', { text: character.spellranks.instinct.toString(), class: 'font-bold' }) ]), - div('flex flex-row items-center gap-2', [ text('Oeuvres'), dom('span', { text: character.spellranks.arts.toString(), class: 'font-bold' }) ]), + div('flex flex-row items-center gap-2', [ text('Précision'), span('font-bold', character.spellranks.precision.toString()) ]), + div('flex flex-row items-center gap-2', [ text('Savoir'), span('font-bold', character.spellranks.knowledge.toString()) ]), + div('flex flex-row items-center gap-2', [ text('Instinct'), span('font-bold', character.spellranks.instinct.toString()) ]), + div('flex flex-row items-center gap-2', [ text('Oeuvres'), span('font-bold', character.spellranks.arts.toString()) ]), ]) ]) ]), div('border-l border-light-35 dark:border-dark-35'), - tabgroup([ - { id: 'actions', title: [ text('Actions') ], content: () => [ - div('flex flex-col gap-8', [ - div('flex flex-col gap-2', [ - div("flex flex-row items-center justify-center gap-4", [ - div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Actions', class: 'h-4' }) ]), - div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), - div('flex flex-row items-center gap-2', [ ...Array(character.action).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]), - ]), - - div('flex flex-col gap-2', [ - div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Attaquer", "Désarmer", "Saisir", "Faire chuter", "Déplacer", "Courir", "Pas de coté", "Charger", "Lancer un sort", "S'interposer", "Se transformer", "Utiliser un objet", "Anticiper une action", "Improviser"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))), - ...(character.lists.action?.map(e => div('flex flex-col gap-1', [ - //div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: e.title }), e.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: e.cost }), text(`point${e.cost > 1 ? 's' : ''} d'action`)]) : undefined]), - markdown(getText(e), undefined, { tags: { a: preview } }), - ])) ?? []) - ]), - ]), - div('flex flex-col gap-2', [ - div("flex flex-row items-center justify-center gap-4", [ - div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Réactions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Réaction', class: 'h-4' }) ]), - div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), - div('flex flex-row items-center gap-2', [ ...Array(character.reaction).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]), - ]), - - div('flex flex-col gap-2', [ - div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Parer", "Esquiver", "Saisir une opportunité", "Prendre en tenaille", "Intercepter"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))), - ...(character.lists.reaction?.map(e => div('flex flex-col gap-1', [ - //div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: e.title }), e.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: e.cost }), text(`point${e.cost > 1 ? 's' : ''} d'action`)]) : undefined]), - markdown(getText(e), undefined, { tags: { a: preview } }), - ])) ?? []) - ]), - ]), - div('flex flex-col gap-2', [ - div("flex flex-row items-center justify-center gap-4", [ - div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions libres" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Action libre', class: 'h-4' }) ]), - div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), - ]), - - div('flex flex-col gap-2', [ - div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Analyser une situation", "Communiquer", "Dégainer", "Attraper un objet"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))), - ...(character.lists.freeaction?.map(e => div('flex flex-col gap-1', [ - //div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: e.title }), e.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: e.cost }), text(`action${e.cost > 1 ? 's' : ''} libre`)]) : undefined]), - markdown(getText(e), undefined, { tags: { a: preview } }), - ])) ?? []) - ]), - ]), - ]), - ] }, - - { id: 'abilities', title: [ text('Aptitudes') ], content: () => this.abilitiesTab(character) }, - - { id: 'spells', title: [ text('Sorts') ], content: () => this.spellTab(character) }, - - { id: 'inventory', title: [ text('Inventaire') ], content: () => [ - - ] }, - - { id: 'notes', title: [ text('Notes') ], content: () => [ - - ] }, - ], { focused: 'abilities', class: { container: 'flex-1 gap-4 px-4 w-[960px]' } }), + this.tabs, ]) ])); } + actionsTab(character: CompiledCharacter) + { + return [ + div('flex flex-col gap-8', [ + div('flex flex-col gap-2', [ + div("flex flex-row items-center justify-center gap-4", [ + div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Actions', class: 'h-4' }) ]), + div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), + div('flex flex-row items-center gap-2', [ ...Array(character.action).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]), + ]), + + div('flex flex-col gap-2', [ + div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Attaquer", "Désarmer", "Saisir", "Faire chuter", "Déplacer", "Courir", "Pas de coté", "Charger", "Lancer un sort", "S'interposer", "Se transformer", "Utiliser un objet", "Anticiper une action", "Improviser"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))), + ...(character.lists.action?.map(e => div('flex flex-col gap-1', [ + //div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: e.title }), e.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: e.cost }), text(`point${e.cost > 1 ? 's' : ''} d'action`)]) : undefined]), + markdown(getText(e), undefined, { tags: { a: preview } }), + ])) ?? []) + ]), + ]), + div('flex flex-col gap-2', [ + div("flex flex-row items-center justify-center gap-4", [ + div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Réactions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Réaction', class: 'h-4' }) ]), + div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), + div('flex flex-row items-center gap-2', [ ...Array(character.reaction).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]), + ]), + + div('flex flex-col gap-2', [ + div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Parer", "Esquiver", "Saisir une opportunité", "Prendre en tenaille", "Intercepter"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))), + ...(character.lists.reaction?.map(e => div('flex flex-col gap-1', [ + //div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: e.title }), e.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: e.cost }), text(`point${e.cost > 1 ? 's' : ''} d'action`)]) : undefined]), + markdown(getText(e), undefined, { tags: { a: preview } }), + ])) ?? []) + ]), + ]), + div('flex flex-col gap-2', [ + div("flex flex-row items-center justify-center gap-4", [ + div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions libres" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Action libre', class: 'h-4' }) ]), + div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), + ]), + + div('flex flex-col gap-2', [ + div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Analyser une situation", "Communiquer", "Dégainer", "Attraper un objet"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))), + ...(character.lists.freeaction?.map(e => div('flex flex-col gap-1', [ + //div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: e.title }), e.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: e.cost }), text(`action${e.cost > 1 ? 's' : ''} libre`)]) : undefined]), + markdown(getText(e), undefined, { tags: { a: preview } }), + ])) ?? []) + ]), + ]), + ]), + ] + } abilitiesTab(character: CompiledCharacter) { return [ @@ -1428,23 +1470,45 @@ export class CharacterSheet } spellTab(character: CompiledCharacter) { + let sortPreference = (localStorage.getItem('character-sort') ?? 'rank') as 'rank' | 'type' | 'element'; + + const sort = (spells: Array<{ id: string, spell?: SpellConfig, source: string }>) => { + spells = spells.filter(e => !!e.spell); + switch(sortPreference) + { + case 'rank': return spells.sort((a, b) => a.spell!.rank - b.spell!.rank || SPELL_ELEMENTS.indexOf(a.spell!.elements[0]!) - SPELL_ELEMENTS.indexOf(b.spell!.elements[0]!)); + case 'type': return spells.sort((a, b) => a.spell!.type.localeCompare(b.spell!.type) || a.spell!.rank - b.spell!.rank); + case 'element': return spells.sort((a, b) => SPELL_ELEMENTS.indexOf(a.spell!.elements[0]!) - SPELL_ELEMENTS.indexOf(b.spell!.elements[0]!) || a.spell!.rank - b.spell!.rank); + default: return spells; + } + }; + const spells = sort([...(character.lists.spells ?? []).map(e => ({ id: e, spell: config.spells.find(_e => _e.id === e), source: 'feature' })), ...character.variables.spells.map(e => ({ id: e, spell: config.spells.find(_e => _e.id === e), source: 'player' }))]).map(e => ({...e, dom: + e.spell ? div('flex flex-col gap-2', [ + div('flex flex-row items-center gap-4', [ dom('span', { class: 'font-semibold text-lg', text: e.spell.name ?? 'Inconnu' }), div('flex-1 border-b border-dashed border-light-50 dark:border-dark-50'), dom('span', { class: 'text-light-70 dark:text-dark-70', text: `${e.spell.cost ?? 0} mana` }) ]), + div('flex flex-row justify-between items-center gap-2 text-light-70 dark:text-dark-70', [ + div('flex flex-row gap-2', [ span('flex flex-row', e.spell.rank === 4 ? 'Sort unique' : `Sort ${e.spell.type === 'instinct' ? 'd\'instinct' : e.spell.type === 'knowledge' ? 'de savoir' : 'de précision'} de rang ${e.spell.rank}`), ...(e.spell.elements ?? []).map(elementDom) ]), + div('flex flex-row gap-2', [ e.spell.concentration ? proses('a', preview, [span('italic text-sm', 'concentration')], { href: '' }) : undefined, span(undefined, typeof e.spell.speed === 'number' ? `${e.spell.speed} minute${e.spell.speed > 1 ? 's' : ''}` : e.spell.speed) ]) + ]), + div('flex flex-row ps-4 p-1 border-l-4 border-light-35 dark:border-dark-35', [ markdown(e.spell.effect) ]), + ]) : undefined })); return [ div('flex flex-col gap-2', [ div('flex flex-row justify-between items-center', [ div('flex flex-row gap-2 items-center', [ dom('span', { class: 'italic tracking-tight text-sm', text: 'Trier par' }), - buttongroup([{ text: 'Rang', value: 'rank' }, { text: 'Type', value: 'type' }, { text: 'Element', value: 'element' }], { value: 'rank', class: { option: 'px-2 py-1 text-sm' } }), + buttongroup<'rank' | 'type' | 'element'>([{ text: 'Rang', value: 'rank' }, { text: 'Type', value: 'type' }, { text: 'Element', value: 'element' }], { value: sortPreference, class: { option: 'px-2 py-1 text-sm' }, onChange: (value) => { localStorage.setItem('character-sort', value); sortPreference = value; this.tabs?.refresh(); } }), + ]), + div('flex flex-row gap-2 items-center', [ + dom('span', { class: ['italic text-sm', { 'text-light-red dark:text-dark-red': character.variables.spells.length !== character.spellslots }], text: `${character.variables.spells.length}/${character.spellslots} sort${character.variables.spells.length > 1 ? 's' : ''} maitrisé${character.variables.spells.length > 1 ? 's' : ''}` }), + button(text('Modifier'), () => this.spellPanel(character, spells), 'py-1 px-4'), ]) - ]) + ]), + div('flex flex-col gap-2', spells.map(e => e.dom)) ]) ] } - spellPanel() + spellPanel(character: CompiledCharacter, spelllist: Array<{ id: string, spell?: SpellConfig, source: string }>) { - if(!this.character) - return; - - const character = this.character.compiled; const availableSpells = Object.values(config.spells).filter(spell => { if (spell.rank === 4) return false; if (character.spellranks[spell.type] < spell.rank) return false; @@ -1465,17 +1529,17 @@ export class CharacterSheet const toggleText = text(state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'), toggleButton = button(toggleText, () => { if(state === 'choosen') { - //this.character.variable('spells', character.variables.spells.filter(e => e !== spell.id)); //TO REWORK + this.character!.variable('spells', character.variables.spells.filter(e => e !== spell.id)); state = 'empty'; } else if(state === 'empty') { - //this.character.variable('spells', [...character.variables.spells, spell.id]); //TO REWORK + this.character!.variable('spells', [...character.variables.spells, spell.id]); //TO REWORK state = 'choosen'; } - //character = compiler.compiled; //TO REWORK toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'; textAmount.textContent = character.variables.spells.length.toString(); + this.tabs?.refresh(); }, "px-2 py-1 text-sm font-normal"); toggleButton.disabled = state === 'given'; return foldable(() => [ @@ -1507,7 +1571,7 @@ export class CharacterSheet ]) ], { open: false, class: { container: "px-2 flex flex-col border-light-35 dark:border-dark-35", content: 'py-2' } }); })) ]); - const blocker = fullblocker([ container ], { closeWhenOutside: true }); + const blocker = fullblocker([ container ], { closeWhenOutside: true, onClose: () => this.character?.saveVariables() }); setTimeout(() => container.setAttribute('data-state', 'active'), 1); } } \ No newline at end of file diff --git a/shared/components.util.ts b/shared/components.util.ts index 4c71b43..62368cf 100644 --- a/shared/components.util.ts +++ b/shared/components.util.ts @@ -35,9 +35,16 @@ export function async(size: 'small' | 'normal' | 'large' = 'normal', fn: Promise } export function button(content: Node, onClick?: () => void, cls?: Class) { - const btn = dom('button', { class: [`text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none + /* + text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 - disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50`, cls], listeners: { click: () => disabled || (onClick && onClick()) } }, [ content ]); + disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50 + */ + const btn = dom('button', { class: [`inline-flex justify-center items-center outline-none leading-none transition-[box-shadow] + text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40 + hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50 + focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 + disabled:text-light-50 dark:disabled:text-dark-50 disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-dashed disabled:border-light-40 dark:disabled:border-dark-40`, cls], listeners: { click: () => disabled || (onClick && onClick()) } }, [ content ]); let disabled = false; Object.defineProperty(btn, 'disabled', { get: () => disabled, @@ -48,7 +55,7 @@ export function button(content: Node, onClick?: () => void, cls?: Class) }) return btn; } -export function buttongroup(options: Array<{ text: string, value: T }>, settings?: { class?: { container?: Class, option?: Class }, value?: T, onChange?: (value: T) => boolean }) +export function buttongroup(options: Array<{ text: string, value: T }>, settings?: { class?: { container?: Class, option?: Class }, value?: T, onChange?: (value: T) => boolean | void }) { let currentValue = settings?.value; const elements = options.map(e => dom('div', { class: [`cursor-pointer text-light-100 dark:text-dark-100 hover:bg-light-30 dark:hover:bg-dark-30 flex items-center justify-center bg-light-20 dark:bg-dark-20 leading-none outline-none @@ -60,7 +67,7 @@ export function buttongroup(options: Array<{ text: string, value: elements.forEach(e => e.toggleAttribute('data-selected', false)); this.toggleAttribute('data-selected', true); - if(!settings?.onChange || settings?.onChange(e.value)) + if(!settings?.onChange || settings?.onChange(e.value) !== false) { currentValue = e.value; } @@ -454,25 +461,38 @@ export function toggle(settings?: { defaultValue?: boolean, change?: (value: boo }, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]); return element; } -export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content: NodeChildren | (() => NodeChildren) }>, settings?: { focused?: string, class?: { container?: Class, tabbar?: Class, title?: Class, content?: Class } }) +export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content: NodeChildren | (() => NodeChildren) }>, settings?: { focused?: string, class?: { container?: Class, tabbar?: Class, title?: Class, content?: Class } }): HTMLDivElement & { refresh: () => void } { - const focus = settings?.focused ?? tabs[0]?.id; + let focus = settings?.focused ?? tabs[0]?.id; const titles = tabs.map((e, i) => dom('div', { class: ['px-2 py-1 border-b border-transparent hover:border-accent-blue data-[focus]:border-accent-blue data-[focus]:border-b-[3px] cursor-pointer', settings?.class?.title], attributes: { 'data-focus': e.id === focus }, listeners: { click: function() { if(this.hasAttribute('data-focus')) return; titles.forEach(e => e.toggleAttribute('data-focus', false)); this.toggleAttribute('data-focus', true); + focus = e.id; const _content = typeof e.content === 'function' ? e.content() : e.content; //@ts-expect-error content.replaceChildren(..._content); }}}, e.title)); const _content = tabs.find(e => e.id === focus)?.content; const content = div(['', settings?.class?.content], typeof _content === 'function' ? _content() : _content); - return div(['flex flex-col', settings?.class?.container], [ + + const container = div(['flex flex-col', settings?.class?.container], [ div(['flex flex-row items-center gap-1', settings?.class?.tabbar], titles), content ]); + Object.defineProperty(container, 'refresh', { + writable: false, + configurable: false, + enumerable: false, + value: () => { + const _content = tabs.find(e => e.id === focus)?.content; + //@ts-expect-error + _content && content.replaceChildren(...(typeof _content === 'function' ? _content() : _content)) + } + }) + return container as HTMLDivElement & { refresh: () => void }; } export interface ToastConfig diff --git a/shared/dom.util.ts b/shared/dom.util.ts index 62e914f..a291cfd 100644 --- a/shared/dom.util.ts +++ b/shared/dom.util.ts @@ -56,9 +56,9 @@ export function div(cls?: Class, children?: NodeChildren): HTMLDivElement { return dom("div", { class: cls }, children); } -export function span(cls?: Class, children?: NodeChildren): HTMLSpanElement +export function span(cls?: Class, text?: string): HTMLSpanElement { - return dom("span", { class: cls }, children); + return dom("span", { class: cls, text: text }); } export function svg(tag: K, properties?: NodeProperties, children?: Omit): SVGElementTagNameMap[K] { @@ -138,7 +138,7 @@ export function icon(name: string, properties?: IconProperties): HTMLElement properties?.mode && el.setAttribute('mode', properties?.mode.toString()); properties?.inline && el.toggleAttribute('inline', properties?.inline); - properties?.noobserver && el.toggleAttribute('noobserver', properties?.noobserver); + el.toggleAttribute('noobserver', properties?.noobserver ?? true); properties?.width && el.setAttribute('width', properties?.width.toString()); properties?.height && el.setAttribute('height', properties?.height.toString()); properties?.flip && el.setAttribute('flip', properties?.flip.toString()); diff --git a/shared/floating.util.ts b/shared/floating.util.ts index 6f0981c..0c50cdd 100644 --- a/shared/floating.util.ts +++ b/shared/floating.util.ts @@ -30,6 +30,7 @@ export interface ModalProperties { priority?: boolean; closeWhenOutside?: boolean; + onClose?: () => boolean | void; } let teleport: HTMLDivElement; @@ -161,6 +162,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H function link(element: HTMLElement) { Object.entries({ 'mouseenter': show, + 'mousemove': show, 'mouseleave': hide, 'focus': show, 'blur': hide, @@ -296,14 +298,13 @@ export function tooltip(container: HTMLElement, txt: string | Text, placement: F export function fullblocker(content: NodeChildren, properties?: ModalProperties) { - const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (() => _modal.remove()) : undefined } }); + const close = () => (!properties?.onClose || properties.onClose() !== false) && _modal.remove(); + const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (close) : undefined } }); const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, ...content]); teleport.appendChild(_modal); - return { - close: () => _modal.remove(), - } + return { close }; } export function modal(content: NodeChildren, properties?: ModalProperties) { diff --git a/shared/proses.ts b/shared/proses.ts index 7e8914e..72efc62 100644 --- a/shared/proses.ts +++ b/shared/proses.ts @@ -1,4 +1,4 @@ -import { dom, icon, type NodeChildren, type Node, div } from "#shared/dom.util"; +import { dom, icon, type NodeChildren, type Node, div, type Class } from "#shared/dom.util"; import { parseURL } from 'ufo'; import render from "#shared/markdown.util"; import { popper } from "#shared/floating.util"; @@ -64,7 +64,7 @@ export const a: Prose = { } } export const preview: Prose = { - custom(properties, children) { + custom(properties: { href: string, class?: Class, size?: 'small' | 'large' }, children) { const href = properties.href as string; const { hash, pathname } = parseURL(href); const router = useRouter(); @@ -76,40 +76,36 @@ export const preview: Prose = { overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined ]); - - if(!!overview) - { - const magicKeys = useMagicKeys(); - popper(el, { - arrow: true, - delay: 150, - offset: 12, - cover: "height", - placement: 'bottom-start', - class: 'data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 data-[state=open]:transition-transform text-light-100 dark:text-dark-100 min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full z-[45]', - content: () => { - return [async('large', Content.getContent(overview.id).then((_content) => { - if(_content?.type === 'markdown') - { - return render((_content as LocalContent<'markdown'>).content ?? '', hash.length > 0 ? hash.substring(1) : undefined, { class: 'w-full max-h-full overflow-auto py-4 px-6' }); - } - if(_content?.type === 'canvas') - { - const canvas = new Canvas((_content as LocalContent<'canvas'>).content); - queueMicrotask(() => canvas.mount()); - return dom('div', { class: 'w-[600px] h-[600px] relative' }, [canvas.container]); - } - return div(''); - }))]; - }, - onShow() { - if(!magicKeys.current.has('control') || magicKeys.current.has('meta')) - return false; - }, - }); - } - - return el; + const magicKeys = useMagicKeys(); + return overview ? popper(el, { + arrow: true, + delay: 150, + offset: 12, + cover: "height", + placement: 'bottom-start', + class: ['data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 data-[state=open]:transition-transform text-light-100 dark:text-dark-100 w-full z-[45]', + { 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px]': !properties?.size || properties.size === 'large', 'max-w-[400px] max-h-[250px]': properties.size === 'small' } + ], + content: () => { + return [async('large', Content.getContent(overview.id).then((_content) => { + if(_content?.type === 'markdown') + { + return render((_content as LocalContent<'markdown'>).content ?? '', hash.length > 0 ? hash.substring(1) : undefined, { class: 'w-full max-h-full overflow-auto py-4 px-6' }); + } + if(_content?.type === 'canvas') + { + const canvas = new Canvas((_content as LocalContent<'canvas'>).content); + queueMicrotask(() => canvas.mount()); + return dom('div', { class: 'w-[600px] h-[600px] relative' }, [canvas.container]); + } + return div(''); + }))]; + }, + onShow() { + if(!magicKeys.current.has('control') || magicKeys.current.has('meta')) + return false; + }, + }) : el; } } export const callout: Prose = { diff --git a/types/character.d.ts b/types/character.d.ts index e983412..26ecba3 100644 --- a/types/character.d.ts +++ b/types/character.d.ts @@ -67,7 +67,7 @@ export type CharacterConfig = { features: Record; enchantments: Record; //TODO items: Record; - lists: Record }>; + lists: Record, values: Record }>; texts: Record; }; export type EnchantementConfig = {