Spell UI, variables saving and mail server fixes (finally working in prod !!!)

This commit is contained in:
Clément Pons 2025-09-30 17:15:49 +02:00
parent 1642cd513f
commit 61d2d144b7
16 changed files with 336 additions and 269 deletions

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -55,7 +55,7 @@ export const characterTable = table("character", {
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
people: text().notNull(), people: text().notNull(),
level: int().notNull().default(1), 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(), aspect: int(),
notes: text(), notes: text(),

View File

@ -4,7 +4,11 @@ declare module 'nitropack'
{ {
interface TaskPayload interface TaskPayload
{ {
type: string type: string;
}
interface TaskResult<RT = unknown>
{
error?: Error | string;
} }
} }
@ -17,7 +21,7 @@ export default defineEventHandler(async (e) => {
return; return;
} }
const id = getRouterParam(e, 'id'); const id = getRouterParam(e, 'id');
const payload: Record<string, any> = await readBody(e); const body: Record<string, any> = await readBody(e);
if(!id) if(!id)
{ {
@ -25,8 +29,11 @@ export default defineEventHandler(async (e) => {
return; return;
} }
payload.type = id; body.data = JSON.parse(body.data);
payload.data = JSON.parse(payload.data); const payload = {
type: id,
data: body,
}
const result = await runTask(id, { const result = await runTask(id, {
payload: payload payload: payload
@ -36,7 +43,7 @@ export default defineEventHandler(async (e) => {
{ {
setResponseStatus(e, 500); setResponseStatus(e, 500);
if(result.error && (result.error as Error).message) if(result.error && result.error.message)
throw result.error; throw result.error;
else if(result.error) else if(result.error)
throw new Error(result.error); throw new Error(result.error);

View File

@ -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;
});

View File

@ -1,7 +1,8 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase'; import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema'; 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) => { export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id"); const id = getRouterParam(e, "id");
@ -11,11 +12,12 @@ export default defineEventHandler(async (e) => {
return; return;
} }
const body = await readBody(e) as CharacterValues; const body = await readValidatedBody(e, CharacterVariablesValidation.safeParse);
if(!body) if(!body.success)
{ {
console.error(body.error);
setResponseStatus(e, 400); setResponseStatus(e, 400);
return; throw body.error;
} }
const db = useDatabase(); const db = useDatabase();
@ -35,8 +37,7 @@ export default defineEventHandler(async (e) => {
} }
db.update(characterTable).set({ db.update(characterTable).set({
health: body.health, variables: body.data
mana: body.mana,
}).where(eq(characterTable.id, parseInt(id, 10))).run(); }).where(eq(characterTable.id, parseInt(id, 10))).run();
setResponseStatus(e, 200); setResponseStatus(e, 200);

View File

@ -1,9 +1,9 @@
<template> <template>
<div style='margin-left: auto; margin-right: auto; width: 75%; font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; line-height: 1.5rem; color: #171717;'> <div style='margin-left: auto; margin-right: auto; width: 75%; font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; line-height: 1.5rem; color: #171717;'>
<div style="margin-left: auto; margin-right: auto; text-align: center;"> <div style="margin-left: auto; margin-right: auto; text-align: center;">
<a href="https://d-any.com"> <a style="display: inline-block;" href="https://d-any.com">
<img src="https://d-any.com/logo.light.png" alt="Logo" title="d[any] logo" width="64" height="64" style="display: block; height: 4rem; width: 4rem; margin-left: auto; margin-right: auto;" /> <img src="https://d-any.com/logo.light.png" alt="Logo" title="d[any] logo" width="64" height="64" style="display: block; height: 4rem; width: 4rem; margin-left: auto; margin-right: auto;" />
<span style="margin-inline-end: 1rem; font-size: 1.5rem; line-height: 2rem; font-weight: 700; font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;">d[any]</span> <span style="margin-inline-end: 1rem; font-size: 1.5rem; color: black; text-decoration: none; line-height: 2rem; font-weight: 700; font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;">d[any]</span>
</a> </a>
</div> </div>
<div style="padding: 1rem;"> <div style="padding: 1rem;">
@ -11,6 +11,6 @@
</div> </div>
</div> </div>
<div style="background-color: #171717;"> <div style="background-color: #171717;">
<p style="padding-top: 1rem; padding-bottom: 1rem; text-align: center; font-size: 0.75rem; line-height: 1rem; color: #fff;">Copyright Peaceultime - d[any] - 2024</p> <p style="padding-top: 1rem; padding-bottom: 1rem; text-align: center; font-size: 0.75rem; line-height: 1rem; color: #fff;">Copyright Peaceultime / d[any] - 2024 / 2025</p>
</div> </div>
</template> </template>

View File

@ -17,10 +17,9 @@ export const templates: Record<string, { component: any, subject: string }> = {
import type Mail from 'nodemailer/lib/mailer'; import type Mail from 'nodemailer/lib/mailer';
interface MailPayload interface MailPayload
{ {
type: 'mail' to: string[];
to: string[] template: string;
template: string data: Record<string, any>;
data: Record<string, any>
} }
const transport = nodemailer.createTransport({ const transport = nodemailer.createTransport({
@ -55,51 +54,59 @@ if(process.env.NODE_ENV === 'production')
}); });
} }
export default async function(e: TaskEvent) { export default defineTask({
try { meta: {
if(e.payload.type !== 'mail') name: 'mail',
{ description: ''
throw new Error(`Données inconnues`); },
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 <no-reply@peaceultime.com>',
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 };
} }
catch(e)
const payload = e.payload as MailPayload;
const template = templates[payload.template];
if(!template)
{ {
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 <no-reply@peaceultime.com>',
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<string, any>): Promise<string> async function render(component: any, data: Record<string, any>): Promise<string>
{ {

View File

@ -558,12 +558,18 @@
} }
}, },
"lists": { "lists": {
"sickness": [ "sickness": {
{ "id": "sickness",
"id": "", "config": {
"name": "Pourriture mortelle"
},
"values": {
"rotted": {
"id": "rotted",
"name": "Pourriture mortelle"
}
} }
] }
}, },
"peoples": { "peoples": {
"e662m19q590kn4dowvssowi1qf8ia7sk": { "e662m19q590kn4dowvssowi1qf8ia7sk": {

View File

@ -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 { z } from "zod/v4";
import characterConfig from '#shared/character-config.json'; import characterConfig from '#shared/character-config.json';
import proses, { preview } from "#shared/proses"; import proses, { preview } from "#shared/proses";
import { button, buttongroup, foldable, input, loading, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util"; 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 { followermenu, fullblocker, tooltip } from "#shared/floating.util";
import { clamp } from "#shared/general.util"; import { clamp } from "#shared/general.util";
import markdown from "#shared/markdown.util"; import markdown from "#shared/markdown.util";
@ -40,6 +40,7 @@ export const defaultCharacter: Character = {
items: [], items: [],
exhaustion: 0, exhaustion: 0,
sickness: [], sickness: [],
poisons: [],
}, },
owner: -1, owner: -1,
@ -113,7 +114,10 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
magicelement: 0, magicelement: 0,
magicinstinct: 0, magicinstinct: 0,
}, },
bonus: {}, bonus: {
abilities: {},
defense: {},
},
resistance: {}, resistance: {},
initiative: 0, initiative: 0,
capacity: 0, capacity: 0,
@ -125,7 +129,7 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
spells: [], spells: [],
}, },
aspect: "", aspect: "",
notes: character.notes ?? "", notes: Object.assign({ public: '', private: '' }, character.notes),
}); });
export const mainStatTexts: Record<MainStat, string> = { export const mainStatTexts: Record<MainStat, string> = {
@ -158,6 +162,10 @@ export const elementTexts: Record<SpellElement, { class: string, text: string }>
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' }, 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' }, 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<Alignment, string> = { export const alignmentTexts: Record<Alignment, string> = {
'loyal_good': 'Loyal bon', 'loyal_good': 'Loyal bon',
@ -205,6 +213,22 @@ export const resistanceTexts: Record<Resistance, string> = {
'instinct': 'Sorts d\'instinct', '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({ export const CharacterValidation = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
@ -216,18 +240,7 @@ export const CharacterValidation = z.object({
leveling: z.record(z.enum(LEVELS.map(String)), z.number().optional()), leveling: z.record(z.enum(LEVELS.map(String)), z.number().optional()),
abilities: z.record(z.enum(ABILITIES), z.number().optional()), abilities: z.record(z.enum(ABILITIES), z.number().optional()),
choices: z.record(z.string(), z.array(z.number())), choices: z.record(z.string(), z.array(z.number())),
variables: z.object({ variables: CharacterVariablesValidation,
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()),
}),
owner: z.number(), owner: z.number(),
username: z.string().optional(), username: z.string().optional(),
visibility: z.enum(["public", "private"]), visibility: z.enum(["public", "private"]),
@ -241,6 +254,7 @@ export class CharacterCompiler
protected _character!: Character; protected _character!: Character;
protected _result!: CompiledCharacter; protected _result!: CompiledCharacter;
protected _buffer: Record<string, PropertySum> = {}; protected _buffer: Record<string, PropertySum> = {};
private _variableDirty: boolean = false;
constructor(character: Character) constructor(character: Character)
{ {
@ -299,6 +313,20 @@ export class CharacterCompiler
{ {
this._character.variables[prop] = value; this._character.variables[prop] = value;
this._result.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) protected add(feature?: string)
{ {
@ -1142,10 +1170,13 @@ class AspectPicker extends BuilderTab
export class CharacterSheet export class CharacterSheet
{ {
user: ComputedRef<User | null>;
character?: CharacterCompiler; character?: CharacterCompiler;
container: HTMLElement = div(); container: HTMLElement = div();
tabs?: HTMLDivElement & { refresh: () => void };
constructor(id: string, user: ComputedRef<User | null>) constructor(id: string, user: ComputedRef<User | null>)
{ {
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/character/${id}`).then(character => { useRequestFetch()(`/api/character/${id}`).then(character => {
@ -1159,9 +1190,16 @@ export class CharacterSheet
this.render(); this.render();
} }
else else
{ throw new Error();
//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() render()
@ -1170,7 +1208,22 @@ export class CharacterSheet
return; return;
const character = this.character.compiled; 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', [ this.container.replaceChildren(div('flex flex-col justify-center gap-1', [
div("flex flex-row gap-4 justify-between", [ div("flex flex-row gap-4 justify-between", [
div(), div(),
@ -1215,10 +1268,9 @@ export class CharacterSheet
]), ]),
div("self-center", [ div("self-center", [
/* user && user.id === character.owner ? this.user.value && this.user.value.id === character.owner ?
button(icon("radix-icons:pencil-2"), () => { button(icon("radix-icons:pencil-2"), () => useRouter().push({ name: 'character-id-edit', params: { id: this.character?.character.id } }), "p-1")
}, "icon") : div()
: div() */
]) ])
]), ]),
@ -1299,7 +1351,7 @@ export class CharacterSheet
div("flex flex-col gap-4 py-1 w-80", [ div("flex flex-col gap-4 py-1 w-80", [
div("flex flex-col py-1 gap-4", [ 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-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") 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-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") 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 ? 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 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' }) : 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' }) : 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' }) : 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' }) : 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' }) : 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' }) : 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' }) : 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' }) : 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' }) : 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' }) : 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' }) : 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, ]) : undefined,
character.mastery.armor > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ 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 > 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' }) : 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' }) : undefined, character.mastery.armor > 2 ? proses('a', preview, [ text('Armure lourde') ], { href: 'regles/annexes/equipement#Les armures lourdes', size: 'small' }) : undefined,
]) : undefined, ]) : undefined,
div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ 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('Précision'), span('font-bold', character.spellranks.precision.toString()) ]),
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('Savoir'), span('font-bold', character.spellranks.knowledge.toString()) ]),
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('Instinct'), span('font-bold', character.spellranks.instinct.toString()) ]),
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('Oeuvres'), span('font-bold', character.spellranks.arts.toString()) ]),
]) ])
]) ])
]), ]),
div('border-l border-light-35 dark:border-dark-35'), div('border-l border-light-35 dark:border-dark-35'),
tabgroup([ this.tabs,
{ 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]' } }),
]) ])
])); ]));
} }
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) abilitiesTab(character: CompiledCharacter)
{ {
return [ return [
@ -1428,23 +1470,45 @@ export class CharacterSheet
} }
spellTab(character: CompiledCharacter) 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 [ return [
div('flex flex-col gap-2', [ div('flex flex-col gap-2', [
div('flex flex-row justify-between items-center', [ div('flex flex-row justify-between items-center', [
div('flex flex-row gap-2 items-center', [ div('flex flex-row gap-2 items-center', [
dom('span', { class: 'italic tracking-tight text-sm', text: 'Trier par' }), 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 => { const availableSpells = Object.values(config.spells).filter(spell => {
if (spell.rank === 4) return false; if (spell.rank === 4) return false;
if (character.spellranks[spell.type] < spell.rank) 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, () => { const toggleText = text(state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'), toggleButton = button(toggleText, () => {
if(state === 'choosen') 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'; state = 'empty';
} }
else if(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'; state = 'choosen';
} }
//character = compiler.compiled; //TO REWORK
toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'; toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter';
textAmount.textContent = character.variables.spells.length.toString(); textAmount.textContent = character.variables.spells.length.toString();
this.tabs?.refresh();
}, "px-2 py-1 text-sm font-normal"); }, "px-2 py-1 text-sm font-normal");
toggleButton.disabled = state === 'given'; toggleButton.disabled = state === 'given';
return foldable(() => [ 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' } }); ]) ], { 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); setTimeout(() => container.setAttribute('data-state', 'active'), 1);
} }
} }

View File

@ -35,9 +35,16 @@ export function async(size: 'small' | 'normal' | 'large' = 'normal', fn: Promise
} }
export function button(content: Node, onClick?: () => void, cls?: Class) 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 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; let disabled = false;
Object.defineProperty(btn, 'disabled', { Object.defineProperty(btn, 'disabled', {
get: () => disabled, get: () => disabled,
@ -48,7 +55,7 @@ export function button(content: Node, onClick?: () => void, cls?: Class)
}) })
return btn; return btn;
} }
export function buttongroup<T extends any>(options: Array<{ text: string, value: T }>, settings?: { class?: { container?: Class, option?: Class }, value?: T, onChange?: (value: T) => boolean }) export function buttongroup<T extends any>(options: Array<{ text: string, value: T }>, settings?: { class?: { container?: Class, option?: Class }, value?: T, onChange?: (value: T) => boolean | void })
{ {
let currentValue = settings?.value; 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 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<T extends any>(options: Array<{ text: string, value:
elements.forEach(e => e.toggleAttribute('data-selected', false)); elements.forEach(e => e.toggleAttribute('data-selected', false));
this.toggleAttribute('data-selected', true); this.toggleAttribute('data-selected', true);
if(!settings?.onChange || settings?.onChange(e.value)) if(!settings?.onChange || settings?.onChange(e.value) !== false)
{ {
currentValue = e.value; 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') ]); }, [ 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; 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() { 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')) if(this.hasAttribute('data-focus'))
return; return;
titles.forEach(e => e.toggleAttribute('data-focus', false)); titles.forEach(e => e.toggleAttribute('data-focus', false));
this.toggleAttribute('data-focus', true); this.toggleAttribute('data-focus', true);
focus = e.id;
const _content = typeof e.content === 'function' ? e.content() : e.content; const _content = typeof e.content === 'function' ? e.content() : e.content;
//@ts-expect-error //@ts-expect-error
content.replaceChildren(..._content); content.replaceChildren(..._content);
}}}, e.title)); }}}, e.title));
const _content = tabs.find(e => e.id === focus)?.content; const _content = tabs.find(e => e.id === focus)?.content;
const content = div(['', settings?.class?.content], typeof _content === 'function' ? _content() : _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), div(['flex flex-row items-center gap-1', settings?.class?.tabbar], titles),
content 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 export interface ToastConfig

View File

@ -56,9 +56,9 @@ export function div(cls?: Class, children?: NodeChildren): HTMLDivElement
{ {
return dom("div", { class: cls }, children); 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<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: Omit<NodeChildren, 'HTMLElement' | 'Text'>): SVGElementTagNameMap[K] export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: Omit<NodeChildren, 'HTMLElement' | 'Text'>): SVGElementTagNameMap[K]
{ {
@ -138,7 +138,7 @@ export function icon(name: string, properties?: IconProperties): HTMLElement
properties?.mode && el.setAttribute('mode', properties?.mode.toString()); properties?.mode && el.setAttribute('mode', properties?.mode.toString());
properties?.inline && el.toggleAttribute('inline', properties?.inline); 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?.width && el.setAttribute('width', properties?.width.toString());
properties?.height && el.setAttribute('height', properties?.height.toString()); properties?.height && el.setAttribute('height', properties?.height.toString());
properties?.flip && el.setAttribute('flip', properties?.flip.toString()); properties?.flip && el.setAttribute('flip', properties?.flip.toString());

View File

@ -30,6 +30,7 @@ export interface ModalProperties
{ {
priority?: boolean; priority?: boolean;
closeWhenOutside?: boolean; closeWhenOutside?: boolean;
onClose?: () => boolean | void;
} }
let teleport: HTMLDivElement; let teleport: HTMLDivElement;
@ -161,6 +162,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
function link(element: HTMLElement) { function link(element: HTMLElement) {
Object.entries({ Object.entries({
'mouseenter': show, 'mouseenter': show,
'mousemove': show,
'mouseleave': hide, 'mouseleave': hide,
'focus': show, 'focus': show,
'blur': hide, 'blur': hide,
@ -296,14 +298,13 @@ export function tooltip(container: HTMLElement, txt: string | Text, placement: F
export function fullblocker(content: NodeChildren, properties?: ModalProperties) 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]); 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); teleport.appendChild(_modal);
return { return { close };
close: () => _modal.remove(),
}
} }
export function modal(content: NodeChildren, properties?: ModalProperties) export function modal(content: NodeChildren, properties?: ModalProperties)
{ {

View File

@ -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 { parseURL } from 'ufo';
import render from "#shared/markdown.util"; import render from "#shared/markdown.util";
import { popper } from "#shared/floating.util"; import { popper } from "#shared/floating.util";
@ -64,7 +64,7 @@ export const a: Prose = {
} }
} }
export const preview: 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 href = properties.href as string;
const { hash, pathname } = parseURL(href); const { hash, pathname } = parseURL(href);
const router = useRouter(); 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 overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
]); ]);
const magicKeys = useMagicKeys();
if(!!overview) return overview ? popper(el, {
{ arrow: true,
const magicKeys = useMagicKeys(); delay: 150,
popper(el, { offset: 12,
arrow: true, cover: "height",
delay: 150, placement: 'bottom-start',
offset: 12, 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]',
cover: "height", { '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' }
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: () => {
content: () => { return [async('large', Content.getContent(overview.id).then((_content) => {
return [async('large', Content.getContent(overview.id).then((_content) => { if(_content?.type === 'markdown')
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' });
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')
if(_content?.type === 'canvas') {
{ const canvas = new Canvas((_content as LocalContent<'canvas'>).content);
const canvas = new Canvas((_content as LocalContent<'canvas'>).content); queueMicrotask(() => canvas.mount());
queueMicrotask(() => canvas.mount()); return dom('div', { class: 'w-[600px] h-[600px] relative' }, [canvas.container]);
return dom('div', { class: 'w-[600px] h-[600px] relative' }, [canvas.container]); }
} return div('');
return div(''); }))];
}))]; },
}, onShow() {
onShow() { if(!magicKeys.current.has('control') || magicKeys.current.has('meta'))
if(!magicKeys.current.has('control') || magicKeys.current.has('meta')) return false;
return false; },
}, }) : el;
});
}
return el;
} }
} }
export const callout: Prose = { export const callout: Prose = {

View File

@ -67,7 +67,7 @@ export type CharacterConfig = {
features: Record<FeatureID, Feature>; features: Record<FeatureID, Feature>;
enchantments: Record<string, EnchantementConfig>; //TODO enchantments: Record<string, EnchantementConfig>; //TODO
items: Record<string, ItemConfig>; items: Record<string, ItemConfig>;
lists: Record<string, { id: string, config: ListConfig, values: Record<string, any> }>; lists: Record<string, { id: string, config: Record<string, any>, values: Record<string, any> }>;
texts: Record<i18nID, Localized>; texts: Record<i18nID, Localized>;
}; };
export type EnchantementConfig = { export type EnchantementConfig = {