Compare commits

...

2 Commits

Author SHA1 Message Date
Peaceultime 04534b2530 Mass updates 2026-01-05 11:33:32 +01:00
Peaceultime 32b6cf4af7 Fix incorrect tag end position 2025-12-23 12:23:06 +01:00
36 changed files with 1886 additions and 12036 deletions

View File

@ -15,11 +15,13 @@
import { Content } from '#shared/content.util'; import { Content } from '#shared/content.util';
import * as Floating from '#shared/floating.util'; import * as Floating from '#shared/floating.util';
import { Toaster } from '#shared/components.util'; import { Toaster } from '#shared/components.util';
import { init } from '#shared/i18n';
onBeforeMount(() => { onBeforeMount(() => {
Content.init(); Content.init();
Floating.init(); Floating.init();
Toaster.init(); Toaster.init();
init()
const unmount = useRouter().afterEach((to, from, failure) => { const unmount = useRouter().afterEach((to, from, failure) => {
if(failure) return; if(failure) return;
@ -183,6 +185,10 @@ iconify-icon
@apply text-light-100 dark:text-dark-100; @apply text-light-100 dark:text-dark-100;
} }
.cm-focused
{
@apply outline-none;
}
.cm-editor .cm-content .cm-editor .cm-content
{ {
@apply caret-light-100 dark:caret-dark-100; @apply caret-light-100 dark:caret-dark-100;

View File

@ -1,5 +1,6 @@
import { relations } from 'drizzle-orm'; import { relations, sql } from 'drizzle-orm';
import { int, text, sqliteTable as table, primaryKey, blob } from 'drizzle-orm/sqlite-core'; import { int, text, sqliteTable as table, primaryKey, blob } from 'drizzle-orm/sqlite-core';
import type { ItemState } from '~/types/character';
export const usersTable = table("users", { export const usersTable = table("users", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
@ -87,8 +88,8 @@ export const campaignTable = table("campaign", {
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
link: text().notNull(), link: text().notNull(),
status: text({ enum: ['PREPARING', 'PLAYING', 'ARCHIVED'] }).default('PREPARING'), status: text({ enum: ['PREPARING', 'PLAYING', 'ARCHIVED'] }).default('PREPARING'),
settings: text({ mode: 'json' }).default('{}'), settings: text({ mode: 'json' }).default({}).$type<{}>(),
inventory: text({ mode: 'json' }).default('[]'), items: text({ mode: 'json' }).default([]).$type<ItemState[]>(),
money: int().default(0), money: int().default(0),
public_notes: text().default(''), public_notes: text().default(''),
dm_notes: text().default(''), dm_notes: text().default(''),
@ -101,13 +102,6 @@ export const campaignCharactersTable = table("campaign_characters", {
id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
character: int().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), character: int().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
}, (table) => [primaryKey({ columns: [table.id, table.character] })]); }, (table) => [primaryKey({ columns: [table.id, table.character] })]);
export const campaignLogsTable = table("campaign_logs", {
id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
target: int(),
timestamp: int({ mode: 'timestamp_ms' }).notNull(),
type: text({ enum: ['ITEM', 'CHARACTER', 'PLACE', 'EVENT', 'FIGHT', 'TEXT'] }),
details: text().notNull(),
}, (table) => [primaryKey({ columns: [table.id, table.target, table.timestamp] })]);
export const usersRelation = relations(usersTable, ({ one, many }) => ({ export const usersRelation = relations(usersTable, ({ one, many }) => ({
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }), data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
@ -153,7 +147,6 @@ export const characterChoicesRelation = relations(characterChoicesTable, ({ one
export const campaignRelation = relations(campaignTable, ({ one, many }) => ({ export const campaignRelation = relations(campaignTable, ({ one, many }) => ({
members: many(campaignMembersTable), members: many(campaignMembersTable),
characters: many(campaignCharactersTable), characters: many(campaignCharactersTable),
logs: many(campaignLogsTable),
owner: one(usersTable, { fields: [campaignTable.owner], references: [usersTable.id], }), owner: one(usersTable, { fields: [campaignTable.owner], references: [usersTable.id], }),
})); }));
export const campaignMembersRelation = relations(campaignMembersTable, ({ one }) => ({ export const campaignMembersRelation = relations(campaignMembersTable, ({ one }) => ({
@ -164,6 +157,3 @@ export const campaignCharacterRelation = relations(campaignCharactersTable, ({ o
campaign: one(campaignTable, { fields: [campaignCharactersTable.id], references: [campaignTable.id], }), campaign: one(campaignTable, { fields: [campaignCharactersTable.id], references: [campaignTable.id], }),
character: one(characterTable, { fields: [campaignCharactersTable.character], references: [characterTable.id], }), character: one(characterTable, { fields: [campaignCharactersTable.character], references: [characterTable.id], }),
})); }));
export const campaignLogsRelation = relations(campaignLogsTable, ({ one }) => ({
campaign: one(campaignTable, { fields: [campaignLogsTable.id], references: [campaignTable.id], }),
}));

View File

@ -98,7 +98,7 @@ onMounted(() => {
}, (item) => item.navigable); }, (item) => item.navigable);
(path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree?.toggle(tree.tree.search('path', e)[0], true)); (path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree?.toggle(tree.tree.search('path', e)[0], true));
treeParent.value!.replaceChildren(tree.container); treeParent.value?.replaceChildren(tree.container);
}) })
} }
}) })

View File

@ -4,6 +4,7 @@ import { unifySlug } from '#shared/general.util';
definePageMeta({ definePageMeta({
guestsGoesTo: '/user/login', guestsGoesTo: '/user/login',
validState: true,
}); });
const id = unifySlug(useRouter().currentRoute.value.params.id ?? "new"); const id = unifySlug(useRouter().currentRoute.value.params.id ?? "new");
const container = useTemplateRef('container'); const container = useTemplateRef('container');

View File

@ -9,6 +9,8 @@ definePageMeta({
const { data: characters, error, status } = await useFetch(`/api/character`); const { data: characters, error, status } = await useFetch(`/api/character`);
const config = characterConfig as CharacterConfig; const config = characterConfig as CharacterConfig;
const { user } = useUserSession();
async function deleteCharacter(id: number) async function deleteCharacter(id: number)
{ {
status.value = "pending"; status.value = "pending";
@ -78,10 +80,11 @@ async function duplicateCharacter(id: number)
</div> </div>
<div v-else class="flex flex-col gap-2 items-center flex-1"> <div v-else class="flex flex-col gap-2 items-center flex-1">
<span class="text-lg font-bold">Vous n'avez pas encore de personnage</span> <span class="text-lg font-bold">Vous n'avez pas encore de personnage</span>
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow] <NuxtLink v-if="user && user.state === 1" 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 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 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 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink> 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 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink>
<div v-else>Veuillez validez votre adresse mail pour pouvoir créer des personnages.</div>
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow] <NuxtLink 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 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 hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50

View File

@ -3,6 +3,8 @@ import characterConfig from '#shared/character-config.json';
import type { CharacterConfig } from '~/types/character'; import type { CharacterConfig } from '~/types/character';
const { data: characters, error, status } = await useFetch(`/api/character`, { params: { visibility: "public" } }); const { data: characters, error, status } = await useFetch(`/api/character`, { params: { visibility: "public" } });
const config = characterConfig as CharacterConfig; const config = characterConfig as CharacterConfig;
const { user } = useUserSession();
</script> </script>
<template> <template>
@ -34,11 +36,14 @@ const config = characterConfig as CharacterConfig;
</div> </div>
<div v-else class="flex flex-col gap-2 items-center flex-1"> <div v-else class="flex flex-col gap-2 items-center flex-1">
<span class="text-lg font-bold">Il n'existe pas encore de personnage public</span> <span class="text-lg font-bold">Il n'existe pas encore de personnage public</span>
Soyez le premier à partager vos créations ! <template v-if="user && user.state === 1">
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow] Soyez le premier à partager vos créations !
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40 <NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50 text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
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 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink> 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 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink>
</template>
<div v-else>Veuillez valider votre adresse mail pour pouvoir créer des personnages.</div>
</div> </div>
</template> </template>
<div v-else> <div v-else>

View File

@ -100,7 +100,7 @@ onMounted(async () => {
editor = new Editor(); editor = new Editor();
Content.ready.then(() => tree.value!.replaceChild(editor.tree.container, load)); Content.ready.then(() => tree.value?.replaceChild(editor.tree.container, load));
container.value.appendChild(editor.container); container.value.appendChild(editor.container);
} }
}); });

View File

@ -4,7 +4,7 @@ import type { Serialize } from 'nitropack';
export type CampaignVariables = { export type CampaignVariables = {
money: number; money: number;
inventory: ItemState[]; items: ItemState[];
}; };
export type Campaign = { export type Campaign = {
id: number; id: number;
@ -16,11 +16,4 @@ export type Campaign = {
characters: Array<Partial<{ character: { id: number, name: string, owner: number } }>>; characters: Array<Partial<{ character: { id: number, name: string, owner: number } }>>;
public_notes: string; public_notes: string;
dm_notes: string; dm_notes: string;
logs: CampaignLog[];
} & CampaignVariables; } & CampaignVariables;
export type CampaignLog = {
target: number;
timestamp: Serialize<Date>;
type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'FIGHT' | 'TEXT';
details: string;
};

View File

@ -57,39 +57,54 @@ export type CharacterVariables = {
money: number; money: number;
}; };
type CommonState = {
capacity?: number;
powercost?: number;
};
type ArmorState = { health?: number };
type WeaponState = { attack?: number | string, hit?: number };
type WondrousState = { };
type MundaneState = { };
type ItemState = { type ItemState = {
id: string; id: string;
amount: number; amount: number;
enchantments?: string[]; enchantments?: string[];
charges?: number; charges?: number;
equipped?: boolean; equipped?: boolean;
state?: any; state?: (ArmorState | WeaponState | WondrousState | MundaneState) & CommonState;
}; };
export type CharacterConfig = { export type CharacterConfig = {
peoples: Record<string, RaceConfig>; peoples: Record<string, RaceConfig>;
training: Record<MainStat, Record<TrainingLevel, FeatureID[]>>; training: Record<MainStat, Record<TrainingLevel, FeatureID[]>>;
spells: Record<string, SpellConfig>; spells: Record<string, SpellConfig | ArtConfig>;
aspects: Record<string, AspectConfig>; aspects: Record<string, AspectConfig>;
features: Record<FeatureID, Feature>; features: Record<FeatureID, Feature>;
enchantments: Record<string, EnchantementConfig>; //TODO enchantments: Record<string, EnchantementConfig>;
items: Record<string, ItemConfig>; items: Record<string, ItemConfig>;
sickness: Record<string, { id: string, name: string, description: string, effect: FeatureID[] }>;
action: Record<string, { id: string, name: string, description: string, cost: number }>; action: Record<string, { id: string, name: string, description: string, cost: number }>;
reaction: Record<string, { id: string, name: string, description: string, cost: number }>; reaction: Record<string, { id: string, name: string, description: string, cost: number }>;
freeaction: Record<string, { id: string, name: string, description: string }>; freeaction: Record<string, { id: string, name: string, description: string }>;
passive: Record<string, { id: string, name: string, description: string }>; passive: Record<string, { id: string, name: string, description: string }>;
texts: Record<i18nID, Localized>; texts: Record<i18nID, Localized>;
//Each of these groups extend an existing feature as they all use the same properties
sickness: Record<FeatureID, { stage: number }>; //TODO
poisons: Record<FeatureID, { difficulty: number, efficienty: number, solubility: number }>; //TODO
dedications: Record<FeatureID, { id: string, name: string, description: i18nID, effect: FeatureID[], requirement: Array<{ stat: MainStat, amount: number }> }>; //TODO
}; };
export type EnchantementConfig = { export type EnchantementConfig = {
id: string;
name: string; //TODO -> TextID name: string; //TODO -> TextID
description: i18nID;
effect: Array<FeatureEquipment | FeatureValue | FeatureList>; effect: Array<FeatureEquipment | FeatureValue | FeatureList>;
power: number; power: number;
restrictions?: Array<'armor' | 'mundane' | 'wondrous' | 'weapon' | `armor/${ArmorConfig['type']}` | `weapon/${WeaponConfig['type'][number]}`>; // Need to respect *any* of the restriction, not every restrictions.
} }
export type ItemConfig = CommonItemConfig & (ArmorConfig | WeaponConfig | WondrousConfig | MundaneConfig); export type ItemConfig = CommonItemConfig & (ArmorConfig | WeaponConfig | WondrousConfig | MundaneConfig);
type CommonItemConfig = { type CommonItemConfig = {
id: string; id: string;
name: string; //TODO -> TextID name: string; //TODO -> TextID
flavoring: i18nID; flavoring?: i18nID;
description: i18nID; description: i18nID;
rarity: 'common' | 'uncommon' | 'rare' | 'legendary'; rarity: 'common' | 'uncommon' | 'rare' | 'legendary';
weight?: number; //Optionnal but highly recommended weight?: number; //Optionnal but highly recommended
@ -101,6 +116,7 @@ type CommonItemConfig = {
effects?: Array<FeatureValue | FeatureEquipment | FeatureList>; effects?: Array<FeatureValue | FeatureEquipment | FeatureList>;
equippable: boolean; equippable: boolean;
consummable: boolean; consummable: boolean;
craft?: { mineral: number, natural: number, processed: number, magical: number };
} }
type ArmorConfig = { type ArmorConfig = {
category: 'armor'; category: 'armor';
@ -126,7 +142,7 @@ export type SpellConfig = {
id: string; id: string;
name: string; //TODO -> TextID name: string; //TODO -> TextID
rank: 1 | 2 | 3 | 4; rank: 1 | 2 | 3 | 4;
type: SpellType; type: Exclude<SpellType, "arts">;
cost: number; cost: number;
speed: "action" | "reaction" | number; speed: "action" | "reaction" | number;
elements: Array<SpellElement>; elements: Array<SpellElement>;
@ -135,6 +151,15 @@ export type SpellConfig = {
range: 'personnal' | number; range: 'personnal' | number;
tags?: string[]; tags?: string[];
}; };
export type ArtConfig = {
id: string;
name: string; //TODO -> TextID
rank: 1 | 2 | 3;
type: "arts";
difficulty: number;
description: string; //TODO -> TextID
tags?: string[];
};
export type RaceConfig = { export type RaceConfig = {
id: string; id: string;
name: string; //TODO -> TextID name: string; //TODO -> TextID
@ -204,16 +229,19 @@ export type CompiledCharacter = {
spellslots: number; //Max spellslots: number; //Max
artslots: number; //Max artslots: number; //Max
spellranks: Record<SpellType, 0 | 1 | 2 | 3>; spellranks: Record<SpellType, 0 | 1 | 2 | 3>;
aspect: string; //ID aspect: {
id: string,
amount: number;
duration: number;
bonus: number;
tier: 0 | 1 | 2;
};
speed: number | false; speed: number | false;
capacity: number | false; capacity: number | false;
initiative: number; initiative: number;
exhaust: number; exhaust: number;
itempower: number; itempower: number;
action: number;
reaction: number;
variables: CharacterVariables, variables: CharacterVariables,
defense: { defense: {
@ -238,10 +266,18 @@ export type CompiledCharacter = {
}; };
bonus: { bonus: {
defense: Partial<Record<MainStat, number>>; defense: Partial<Record<MainStat, number>>; //Defense aux jets de resistance
abilities: Partial<Record<Ability, number>>; abilities: Partial<Record<Ability, number>>;
spells: {
type: Partial<Record<SpellType, number>>;
rank: Partial<Record<1 | 2 | 3 | 4, number>>;
elements: Partial<Record<SpellElement, number>>;
};
weapon: Partial<Record<WeaponType, number>>;
}; //Any special bonus goes here }; //Any special bonus goes here
resistance: Record<string, number>; resistance: Partial<Record<Resistance, number>>; //Bonus à l'attaque
craft: { level: number, bonus: number };
modifier: Record<MainStat, number>; modifier: Record<MainStat, number>;
abilities: Partial<Record<Ability, number>>; abilities: Partial<Record<Ability, number>>;

View File

@ -17,5 +17,4 @@ type CanvasPreferences = {
export type Localized = { export type Localized = {
fr_FR?: string; fr_FR?: string;
en_US?: string; en_US?: string;
default: string;
} }

View File

@ -24,6 +24,7 @@
"hast": "^1.0.0", "hast": "^1.0.0",
"hast-util-heading": "^3.0.0", "hast-util-heading": "^3.0.0",
"hast-util-heading-rank": "^3.0.0", "hast-util-heading-rank": "^3.0.0",
"hast-util-select": "^6.0.4",
"iconify-icon": "^3.0.2", "iconify-icon": "^3.0.2",
"lodash.capitalize": "^4.2.1", "lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.2", "mdast-util-find-and-replace": "^3.0.2",
@ -826,6 +827,8 @@
"basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="],
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
"better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="], "better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
@ -956,6 +959,8 @@
"css-select": ["css-select@4.3.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", "domhandler": "^4.3.1", "domutils": "^2.8.0", "nth-check": "^2.0.1" } }, "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ=="], "css-select": ["css-select@4.3.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", "domhandler": "^4.3.1", "domutils": "^2.8.0", "nth-check": "^2.0.1" } }, "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ=="],
"css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="],
"css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="], "css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
@ -1018,6 +1023,8 @@
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], "dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="],
@ -1194,6 +1201,8 @@
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="],
"hast-util-heading": ["hast-util-heading@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-is-element": "^3.0.0" } }, "sha512-SykluYSLOs7z72hUUcztJpPV20alz58pfbi8g/NckXPnJ4OFVwPidNz3XOqgSNu5MTeFvde5c0cFVUk319Qlqw=="], "hast-util-heading": ["hast-util-heading@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-is-element": "^3.0.0" } }, "sha512-SykluYSLOs7z72hUUcztJpPV20alz58pfbi8g/NckXPnJ4OFVwPidNz3XOqgSNu5MTeFvde5c0cFVUk319Qlqw=="],
"hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="], "hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="],
@ -1204,10 +1213,14 @@
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-select": ["hast-util-select@6.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "bcp-47-match": "^2.0.0", "comma-separated-tokens": "^2.0.0", "css-selector-parser": "^3.0.0", "devlop": "^1.0.0", "direction": "^2.0.0", "hast-util-has-property": "^3.0.0", "hast-util-to-string": "^3.0.0", "hast-util-whitespace": "^3.0.0", "nth-check": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="], "hast-util-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="],
"hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],

BIN
db.sqlite

Binary file not shown.

View File

@ -0,0 +1,2 @@
ALTER TABLE `campaign` RENAME COLUMN "inventory" TO "items";--> statement-breakpoint
DROP TABLE `campaign_logs`;

View File

@ -0,0 +1,929 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0a61f9fe-e6b1-4ac4-9d58-206dbbcf9cda",
"prevId": "fdee27cd-0188-4e54-bc2c-a96a375e83a1",
"tables": {
"campaign_characters": {
"name": "campaign_characters",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"campaign_characters_id_campaign_id_fk": {
"name": "campaign_characters_id_campaign_id_fk",
"tableFrom": "campaign_characters",
"tableTo": "campaign",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
},
"campaign_characters_character_character_id_fk": {
"name": "campaign_characters_character_character_id_fk",
"tableFrom": "campaign_characters",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"campaign_characters_id_character_pk": {
"columns": [
"id",
"character"
],
"name": "campaign_characters_id_character_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"campaign_members": {
"name": "campaign_members",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user": {
"name": "user",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"campaign_members_id_campaign_id_fk": {
"name": "campaign_members_id_campaign_id_fk",
"tableFrom": "campaign_members",
"tableTo": "campaign",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
},
"campaign_members_user_users_id_fk": {
"name": "campaign_members_user_users_id_fk",
"tableFrom": "campaign_members",
"tableTo": "users",
"columnsFrom": [
"user"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"campaign_members_id_user_pk": {
"columns": [
"id",
"user"
],
"name": "campaign_members_id_user_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"campaign": {
"name": "campaign",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"link": {
"name": "link",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'PREPARING'"
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'{}'"
},
"items": {
"name": "items",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"money": {
"name": "money",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"public_notes": {
"name": "public_notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"dm_notes": {
"name": "dm_notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
}
},
"indexes": {},
"foreignKeys": {
"campaign_owner_users_id_fk": {
"name": "campaign_owner_users_id_fk",
"tableFrom": "campaign",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_abilities": {
"name": "character_abilities",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ability": {
"name": "ability",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"max": {
"name": "max",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"character_abilities_character_character_id_fk": {
"name": "character_abilities_character_character_id_fk",
"tableFrom": "character_abilities",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_abilities_character_ability_pk": {
"columns": [
"character",
"ability"
],
"name": "character_abilities_character_ability_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_choices": {
"name": "character_choices",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_choices_character_character_id_fk": {
"name": "character_choices_character_character_id_fk",
"tableFrom": "character_choices",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_choices_character_id_choice_pk": {
"columns": [
"character",
"id",
"choice"
],
"name": "character_choices_character_id_choice_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_leveling": {
"name": "character_leveling",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_leveling_character_character_id_fk": {
"name": "character_leveling_character_character_id_fk",
"tableFrom": "character_leveling",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_leveling_character_level_pk": {
"columns": [
"character",
"level"
],
"name": "character_leveling_character_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character": {
"name": "character",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"people": {
"name": "people",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"variables": {
"name": "variables",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"health\": 0,\"mana\": 0,\"spells\": [],\"items\": [],\"exhaustion\": 0,\"sickness\": [],\"poisons\": []}'"
},
"aspect": {
"name": "aspect",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"public_notes": {
"name": "public_notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"private_notes": {
"name": "private_notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'private'"
},
"thumbnail": {
"name": "thumbnail",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_owner_users_id_fk": {
"name": "character_owner_users_id_fk",
"tableFrom": "character",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_training": {
"name": "character_training",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stat": {
"name": "stat",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_training_character_character_id_fk": {
"name": "character_training_character_character_id_fk",
"tableFrom": "character_training",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_training_character_stat_level_pk": {
"columns": [
"character",
"stat",
"level"
],
"name": "character_training_character_stat_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_validation": {
"name": "email_validation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_content": {
"name": "project_content",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_files": {
"name": "project_files",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"project_files_path_unique": {
"name": "project_files_path_unique",
"columns": [
"path"
],
"isUnique": true
}
},
"foreignKeys": {
"project_files_owner_users_id_fk": {
"name": "project_files_owner_users_id_fk",
"tableFrom": "project_files",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastTimestamp": {
"name": "lastTimestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"campaign\".\"inventory\"": "\"campaign\".\"items\""
}
},
"internal": {
"indexes": {}
}
}

View File

@ -183,6 +183,13 @@
"when": 1764763792974, "when": 1764763792974,
"tag": "0025_majestic_grim_reaper", "tag": "0025_majestic_grim_reaper",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1766864228037,
"tag": "0026_absurd_firelord",
"breakpoints": true
} }
] ]
} }

View File

@ -168,9 +168,7 @@ export default defineNuxtConfig({
sources: ['/api/__sitemap__/urls'] sources: ['/api/__sitemap__/urls']
}, },
experimental: { experimental: {
componentIslands: { noVueServer: true,
selectiveClient: true,
},
defaults: { defaults: {
nuxtLink: { nuxtLink: {
prefetchOn: { prefetchOn: {
@ -189,9 +187,13 @@ export default defineNuxtConfig({
vite: { vite: {
server: { server: {
hmr: { hmr: {
protocol: 'wss',
host: 'localhost',
port: 3000,
clientPort: 3000, clientPort: 3000,
} path: '/ws'
} },
},
}, },
vue: { vue: {
compilerOptions: { compilerOptions: {

View File

@ -28,6 +28,7 @@
"hast": "^1.0.0", "hast": "^1.0.0",
"hast-util-heading": "^3.0.0", "hast-util-heading": "^3.0.0",
"hast-util-heading-rank": "^3.0.0", "hast-util-heading-rank": "^3.0.0",
"hast-util-select": "^6.0.4",
"iconify-icon": "^3.0.2", "iconify-icon": "^3.0.2",
"lodash.capitalize": "^4.2.1", "lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.2", "mdast-util-find-and-replace": "^3.0.2",

View File

@ -24,7 +24,6 @@ export default defineEventHandler(async (e) => {
members: { with: { member: { columns: { username: true, id: true } } }, columns: { id: false, user: false } }, members: { with: { member: { columns: { username: true, id: true } } }, columns: { id: false, user: false } },
characters: { with: { character: { columns: { id: true, name: true, owner: true } } }, columns: { character: false } }, characters: { with: { character: { columns: { id: true, name: true, owner: true } } }, columns: { character: false } },
owner: { columns: { username: true, id: true } }, owner: { columns: { username: true, id: true } },
logs: { columns: { details: true, target: true, timestamp: true, type: true }, orderBy: ({ timestamp }) => timestamp },
}, },
where: ({ id: _id }) => eq(_id, parseInt(id, 10)), where: ({ id: _id }) => eq(_id, parseInt(id, 10)),
}).sync(); }).sync();

View File

@ -12,7 +12,7 @@ export default defineEventHandler(async (e) => {
} }
const id = parseInt(params, 10); const id = parseInt(params, 10);
const body = await readValidatedBody(e, CampaignValidation.safeParse); const body = await readValidatedBody(e, CampaignValidation.partial().safeParse);
if(!body.success) if(!body.success)
{ {
setResponseStatus(e, 400); setResponseStatus(e, 400);

View File

@ -1,20 +0,0 @@
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;
}
setResponseStatus(e, 200);
return {};
});

View File

@ -1,20 +0,0 @@
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;
}
setResponseStatus(e, 200);
return;
});

View File

@ -1,5 +1,4 @@
import type { SocketMessage } from "#shared/websocket.util"; import type { SocketMessage } from "#shared/websocket.util";
import type { User } from "~/types/auth";
export default defineWebSocketHandler({ export default defineWebSocketHandler({
message(peer, message) { message(peer, message) {
@ -31,14 +30,11 @@ export default defineWebSocketHandler({
const topic = `campaigns/${id}`; const topic = `campaigns/${id}`;
peer.subscribe(topic); peer.subscribe(topic);
peer.publish(topic, { type: 'status', data: [{ user: (peer.context.user as User).id, status: true }] });
peer.send({ type: 'status', data: peer.peers.values().filter(e => e.topics.has(topic)).map(e => ({ user: (e.context.user as User).id, status: true })).toArray() })
}, },
close(peer, details) { close(peer, details) {
const id = new URL(peer.request.url).pathname.split('/').slice(-1)[0]; const id = new URL(peer.request.url).pathname.split('/').slice(-1)[0];
if(!id) return peer.close(); if(!id) return peer.close();
peer.publish(`campaigns/${id}`, { type: 'status', data: [{ user: (peer.context.user as User).id, status: false }] });
peer.unsubscribe(`campaigns/${id}`); peer.unsubscribe(`campaigns/${id}`);
} }
}); });

View File

@ -1,14 +1,20 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { User } from "~/types/auth"; import type { User } from "~/types/auth";
import type { Campaign, CampaignLog } from "~/types/campaign"; import characterConfig from '#shared/character-config.json';
import { div, dom, icon, span, svg, text, type RedrawableHTML } from "#shared/dom.util"; import type { Campaign } from "~/types/campaign";
import { button, loading, tabgroup, Toaster } from "#shared/components.util"; import { div, dom, icon, span, text, type RedrawableHTML } from "#shared/dom.util";
import { CharacterCompiler } from "#shared/character.util"; import { button, foldable, loading, numberpicker, tabgroup, Toaster } from "#shared/components.util";
import { CharacterCompiler, colorByRarity, stateFactory, subnameFactory } from "#shared/character.util";
import { modal, tooltip } from "#shared/floating.util"; import { modal, tooltip } from "#shared/floating.util";
import markdown from "#shared/markdown.util"; import markdown from "#shared/markdown.util";
import { preview } from "#shared/proses"; import { preview } from "#shared/proses";
import { format } from "#shared/general.util";
import { Socket } from "#shared/websocket.util"; import { Socket } from "#shared/websocket.util";
import { reactive } from "#shared/reactive";
import type { Character, CharacterConfig } from "~/types/character";
import { MarkdownEditor } from "./editor.util";
import { getText } from "./i18n";
const config = characterConfig as CharacterConfig;
export const CampaignValidation = z.object({ export const CampaignValidation = z.object({
id: z.number(), id: z.number(),
@ -21,10 +27,14 @@ export const CampaignValidation = z.object({
class CharacterPrinter class CharacterPrinter
{ {
compiler?: CharacterCompiler; compiler?: CharacterCompiler;
container: RedrawableHTML = div('flex flex-col gap-2 px-1'); container: RedrawableHTML;
name: string;
id: number;
constructor(character: number, name: string) constructor(character: number, name: string)
{ {
this.container.replaceChildren(div('flex flex-row justify-between items-center', [ span('text-bold text-xl', name), loading('small')])); this.id = character;
this.name = name;
this.container = div('flex flex-col gap-2 px-1', [ div('flex flex-row justify-between items-center', [ span('text-bold text-xl', name), loading('small') ]) ]);
useRequestFetch()(`/api/character/${character}`).then((character) => { useRequestFetch()(`/api/character/${character}`).then((character) => {
if(character) if(character)
{ {
@ -48,48 +58,13 @@ class CharacterPrinter
}) })
} }
} }
type PlayerState = {
statusDOM: RedrawableHTML;
statusTooltip: Text;
dom: RedrawableHTML;
user: { id: number, username: string };
};
const logType: Record<CampaignLog['type'], string> = {
CHARACTER: ' a rencontré ',
FIGHT: ' a affronté ',
ITEM: ' a obtenu ',
PLACE: ' est arrivé ',
TEXT: ' ',
}
const activity = {
online: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-green dark:bg-dark-green border-light-green dark:border-dark-green', text: 'En ligne' },
afk: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-yellow dark:bg-dark-yellow border-light-yellow dark:border-dark-yellow', text: 'Inactif' },
offline: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content border-dashed border-light-50 dark:border-dark-50 bg-light-0 dark:bg-dark-0', text: 'Hors ligne' },
}
function defaultPlayerState(user: { id: number, username: string }): PlayerState
{
const statusTooltip = text(activity.offline.text), statusDOM = span(activity.offline.class);
return {
statusDOM,
statusTooltip,
dom: div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), user.username, 'bottom'), tooltip(statusDOM, statusTooltip, 'bottom') ]),
user
}
}
export class CampaignSheet export class CampaignSheet
{ {
private user: ComputedRef<User | null>; private user: ComputedRef<User | null>;
private campaign?: Campaign; private campaign?: Campaign;
private characters!: Array<CharacterPrinter>;
container: RedrawableHTML = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6'); container: RedrawableHTML = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6');
private dm!: PlayerState;
private players!: Array<PlayerState>;
private characters!: Array<CharacterPrinter>;
private characterList!: RedrawableHTML;
private tab: string = 'campaign';
ws?: Socket; ws?: Socket;
constructor(id: string, user: ComputedRef<User | null>) constructor(id: string, user: ComputedRef<User | null>)
@ -100,54 +75,40 @@ export class CampaignSheet
useRequestFetch()(`/api/campaign/${id}`).then((campaign) => { useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
if(campaign) if(campaign)
{ {
this.campaign = campaign; this.campaign = reactive(campaign);
this.dm = defaultPlayerState(campaign.owner); this.characters = reactive(campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name)));
this.players = campaign.members.map(e => defaultPlayerState(e.member)); /* this.ws = new Socket(`/ws/campaign/${id}`, true);
this.characters = campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name));
this.ws = new Socket(`/ws/campaign/${id}`, true);
this.ws.handleMessage<{ user: number, status: boolean }[]>('status', (users) => {
users.forEach(user => {
if(this.dm.user.id === user.user)
{
this.dm.statusTooltip.textContent = activity[user.status ? 'online' : 'offline'].text;
this.dm.statusDOM.className = activity[user.status ? 'online' : 'offline'].class;
}
else
{
const player = this.players.find(e => e.user.id === user.user)
if(player)
{
player.statusTooltip.textContent = activity[user.status ? 'online' : 'offline'].text;
player.statusDOM.className = activity[user.status ? 'online' : 'offline'].class;
}
}
})
});
this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('character', (character) => { this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('character', (character) => {
if(character.action === 'ADD') if(character.action === 'ADD')
{ {
const printer = new CharacterPrinter(character.id, character.name); this.characters.push(new CharacterPrinter(character.id, character.name));
this.characters.push(printer);
this.characterList.appendChild(printer.container);
} }
else if(character.action === 'REMOVE') else if(character.action === 'REMOVE')
{ {
const idx = this.characters.findIndex(e => e.compiler?.character.id !== character.id); const idx = this.characters.findIndex(e => e.compiler?.character.id !== character.id);
if(idx !== -1) idx !== -1 && this.characters.splice(idx, 1);
{
this.characters[idx]!.container.remove();
this.characters.splice(idx, 1);
}
} }
}); });
this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('player', () => { this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('player', (player) => {
this.render(); if(player.action === 'ADD')
{
this.campaign?.members.push({ member: { id: player.id, username: player.name } });
}
else if(player.action === 'REMOVE')
{
const idx = this.campaign?.members.findIndex(e => e.member.id !== player.id);
idx && idx !== -1 && this.characters.splice(idx, 1);
}
}); });
this.ws.handleMessage<void>('hardsync', () => { this.ws.handleMessage<void>('hardsync', () => {
this.render(); useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
}); this.campaign = reactive(campaign);
this.characters = reactive(campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name)));
});
}); */
document.title = `d[any] - Campagne ${campaign.name}`; document.title = `d[any] - Campagne ${campaign.name}`;
this.render(); this.render();
@ -167,9 +128,26 @@ export class CampaignSheet
])); ]));
}); });
} }
private logText(log: CampaignLog) save()
{ {
return `${log.target === 0 ? 'Le groupe' : this.players.find(e => e.user.id === log.target)?.user.username ?? 'Un personange'}${logType[log.type]}${log.details}`; if(!this.campaign)
return;
return useRequestFetch()(`/api/campaign/${this.campaign.id}`, {
method: 'POST',
body: {
name: this.campaign.name,
status: this.campaign.status,
public_notes: this.campaign.public_notes,
dm_notes: this.campaign.dm_notes,
},
}).then(() => {}).catch(() => {
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
});
}
saveVariables()
{
} }
private render() private render()
{ {
@ -178,12 +156,13 @@ export class CampaignSheet
if(!campaign) if(!campaign)
return; return;
this.characterList = div('flex flex-col gap-2', this.characters.map(e => e.container)); const charPicker = this.characterPicker();
this.container.replaceChildren(div('grid grid-cols-3 gap-2', [ this.container.replaceChildren(div('grid grid-cols-3 gap-2', [
div('flex flex-row gap-2 items-center py-2', [ div('flex flex-row gap-2 items-center py-2', [
this.dm.dom, div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), campaign.owner.username, 'bottom') ]),
div('border-l h-full w-0 border-light-40 dark:border-dark-40'), div('border-l h-full w-0 border-light-40 dark:border-dark-40'),
div('flex flex-row gap-1', this.players.map(e => e.dom)), div('flex flex-row gap-1', { list: campaign.members, render: (member) => div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), member.member.username, 'bottom') ]) }),
]), ]),
div('flex flex-1 flex-col items-center justify-center gap-2', [ div('flex flex-1 flex-col items-center justify-center gap-2', [
span('text-2xl font-serif font-bold italic', campaign.name), span('text-2xl font-serif font-bold italic', campaign.name),
@ -192,86 +171,138 @@ export class CampaignSheet
div('flex flex-1 flex-col items-center justify-center', [ div('flex flex-1 flex-col items-center justify-center', [
div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [ div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [
dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]), dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]),
button(icon('radix-icons:clipboard', { width: 16, height: 16 }), () => {}, 'p-1'), button(icon(() => 'radix-icons:clipboard', { width: 16, height: 16 }), () => {}, 'p-1'),
]), ]),
]), ]),
]), ]),
div('flex flex-row gap-4 flex-1 h-0', [ div('flex flex-row gap-4 flex-1 h-0', [
div('flex flex-col gap-2', [ div('flex flex-col gap-2', [
div('flex flex-row items-center gap-4 w-[320px]', [ span('font-bold text-lg', 'Etat'), div('border-t border-light-40 dark:border-dark-40 border-dashed flex-1') ]), div('flex flex-row items-center gap-4 w-[320px]', [ span('font-bold text-lg', 'Etat'), div('border-t border-light-40 dark:border-dark-40 border-dashed flex-1') ]),
this.characterList, div('flex flex-col gap-2', { list: this.characters, render: (e) => e.container }),
div('px-8 py-4 w-full flex', [ div('px-8 py-4 w-full flex', [
button([ button([
icon('radix-icons:plus-circled', { width: 24, height: 24 }), icon('radix-icons:plus-circled', { width: 24, height: 24 }),
span('text-sm', 'Ajouter un personnage'), span('text-sm', 'Ajouter un personnage'),
], () => { ], () => charPicker.show(), 'flex flex-col flex-1 gap-2 p-4 items-center justify-center text-light-60 dark:text-dark-60'),
const load = loading('normal');
let characters: RedrawableHTML[] = [];
const close = modal([
div('flex flex-col gap-4 items-center min-w-[480px] min-h-24', [
span('text-xl font-bold', 'Mes personnages'),
load,
]),
], { closeWhenOutside: true, priority: true, class: { container: 'max-w-[560px]' } }).close;
useRequestFetch()(`/api/character`).then((list) => {
characters = list?.map(e => div('border border-light-40 dark:border-dark-40 p-2 flex flex-col w-[140px]', [
span('font-bold', e.name),
span('', `Niveau ${e.level}`),
button(text('Ajouter'), () => useRequestFetch()(`/api/character/${e.id}/campaign/${this.campaign!.id}`, { method: 'POST' }).then(() => this.ws!.send('character', { id: e.id, name: e.name, action: 'ADD', })).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(close)),
])) ?? [];
}).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(() => {
load.replaceWith(div('grid grid-cols-3 gap-2', characters.length > 0 ? characters : [span('text-light-60 dark:text-dark-60 text-sm italic', 'Vous n\'avez pas de personnage disponible')]));
});
}, 'flex flex-col flex-1 gap-2 p-4 items-center justify-center text-light-60 dark:text-dark-60'),
]) ])
]), ]),
div('flex h-full border-l border-light-40 dark:border-dark-40'), div('flex h-full border-l border-light-40 dark:border-dark-40'),
div('flex flex-col', [ div('flex flex-col', [
tabgroup([ tabgroup([
{ id: 'campaign', title: [ text('Campagne') ], content: () => [ { id: 'campaign', title: [ text('Campagne') ], content: () => {
markdown(campaign.public_notes, '', { tags: { a: preview }, class: 'px-2' }), const editor = new MarkdownEditor();
] }, editor.content = campaign.public_notes;
{ id: 'inventory', title: [ text('Inventaire') ], content: () => [ editor.onChange = (v) => campaign.public_notes = v;
] },
{ id: 'logs', title: [ text('Logs') ], content: () => {
let lastDate: Date = new Date(0);
const logs = campaign.logs.flatMap(e => {
const date = new Date(e.timestamp), arr = [];
if(Math.floor(lastDate.getTime() / 86400000) < Math.floor(date.getTime() / 86400000))
{
lastDate = date;
arr.push(div('flex flex-row gap-2 items-center relative -left-2 mx-px', [
div('w-3 h-3 border-2 rounded-full bg-light-40 dark:bg-dark-40 border-light-0 dark:border-dark-0'),
div('flex flex-row gap-2 items-center flex-1', [
div('flex-1 border-t border-light-40 dark:border-dark-40 border-dashed'),
span('text-light-70 dark:text-dark-70 text-sm italic tracking-tight', format(date, 'dd MMMM yyyy')),
div('flex-1 border-t border-light-40 dark:border-dark-40 border-dashed'),
])
]))
}
arr.push(div('flex flex-row gap-2 items-center relative -left-2 mx-px group', [
div('w-3 h-3 border-2 rounded-full bg-light-40 dark:bg-dark-40 border-light-0 dark:border-dark-0'),
div('flex flex-row items-center', [ svg('svg', { class: 'fill-light-40 dark:fill-dark-40', attributes: { width: "8", height: "12", viewBox: "0 0 6 9" } }, [svg('path', { attributes: { d: "M0 4.5L6 0L6 9L0 4.5Z" } })]), span('px-4 py-2 bg-light-25 dark:bg-dark-25 border border-light-40 dark:border-dark-40', this.logText(e)) ]),
span('italic text-xs tracking-tight text-light-70 dark:text-dark-70 font-mono invisible group-hover:visible', format(new Date(e.timestamp), 'HH:mm:ss')),
]));
return arr;
});
return [ return [
campaign.logs.length > 0 ? div('flex flex-row ps-12 py-4', [ this.user.value && this.user.value.id === campaign.owner.id ? div('flex flex-col gap-4 p-1', [ div('flex flex-row justify-between items-center', [ span('text-xl font-bold', 'Notes destinées aux joueurs'), div('flex flex-row gap-2', [ tooltip(button(icon('radix-icons:paper-plane', { width: 16, height: 16 }), () => this.save(), 'p-1 items-center justify-center'), 'Enregistrer', 'right') ]) ]), div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 h-64', [editor.dom])]) : markdown(campaign.public_notes, '', { tags: { a: preview }, class: 'px-2' }),
div('border-l-2 border-light-40 dark:border-dark-40 relative before:absolute before:block before:border-[6px] before:border-b-[12px] before:-left-px before:-translate-x-1/2 before:border-transparent before:border-b-light-40 dark:before:border-b-dark-40 before:-top-3'), ];
div('flex flex-col-reverse gap-8 py-4', logs),
]) : div('flex py-4 px-16', [ span('italic text-light-70 dark:text-darl-70', 'Aucune entrée pour le moment') ]),
]
} }, } },
{ id: 'inventory', title: [ text('Inventaire') ], content: () => this.items() },
{ id: 'settings', title: [ text('Paramètres') ], content: () => [ { id: 'settings', title: [ text('Paramètres') ], content: () => [
] }, ] },
{ id: 'ressources', title: [ text('Ressources') ], content: () => [ { id: 'ressources', title: [ text('Ressources') ], content: () => [
] } ] }
], { focused: 'campaign', class: { container: 'max-w-[900px] w-[900px] h-full', content: 'overflow-auto', tabbar: 'gap-4' } }), ], { focused: 'campaign', class: { container: 'max-w-[900px] w-[900px] h-full', content: 'overflow-auto p-2', tabbar: 'gap-4 border-b border-light-30 dark:border-dark-30' } }),
]) ])
])) ]))
} }
characterPicker()
{
const current = reactive({
characters: [] as Character[],
loading: true,
});
const _modal = modal([
div('flex flex-col gap-4 items-center min-w-[480px] min-h-24', [
span('text-xl font-bold', 'Mes personnages'),
div('grid grid-cols-3 gap-2', { list: () => current.characters, render: (e) => div('border border-light-40 dark:border-dark-40 p-2 flex flex-col w-[140px]', [
span('font-bold', e.name),
span('', `Niveau ${e.level}`),
button(text('Ajouter'), () => useRequestFetch()(`/api/character/${e.id}/campaign/${this.campaign!.id}`, { method: 'POST' }).then(() => this.ws!.send('character', { id: e.id, name: e.name, action: 'ADD', })).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(_modal.close)),
]), fallback: () => div('flex justify-center items-center col-span-3', [current.loading ? loading('large') : span('text-light-60 dark:text-dark-60 text-sm italic', 'Vous n\'avez pas de personnage disponible')]) }),
]),
], { closeWhenOutside: true, priority: true, class: { container: 'max-w-[560px]' }, open: false });
return { show: () => {
current.loading = true;
useRequestFetch()(`/api/character`).then((list) => {
current.characters = list?.filter(e => !this.characters.find(_e => _e.compiler?.character.id === e.id)) ?? [];
}).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(() => {
current.loading = false;
});
_modal.open();
}, hide: _modal.close }
}
items()
{
if(!this.campaign)
return [];
const items = this.campaign.items;
const money = {
readonly: dom('div', { listeners: { click: () => { money.readonly.replaceWith(money.edit); money.edit.focus(); } }, class: 'cursor-pointer border border-transparent hover:border-light-40 dark:hover:border-dark-40 px-2 py-px flex flex-row gap-1 items-center' }, [ span('text-lg font-bold', () => this.campaign!.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]),
edit: numberpicker({ defaultValue: this.campaign.money, change: v => { this.campaign!.money = v; this.saveVariables(); money.edit.replaceWith(money.readonly); }, blur: v => { this.campaign!.money = v; this.saveVariables(); money.edit.replaceWith(money.readonly); }, min: 0, class: 'w-24' }),
};
return [
div('flex flex-col gap-2', [
div('flex flex-row justify-between items-center', [
div('flex flex-row justify-end items-center gap-8', [
div('flex flex-row gap-1 items-center', [ span('italic text-sm', 'Argent'), this.user.value && this.user.value.id === this.campaign.owner.id ? money.readonly : div('cursor-pointer px-2 py-px flex flex-row gap-1 items-center', [ span('text-lg font-bold', () => this.campaign!.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]) ]),
])
]),
div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: this.campaign.items, render: e => {
const item = config.items[e.id];
if(!item) return;
const itempower = () => (item.powercost ?? 0) + (e.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0);
const price = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.price }], [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.price }), () => item.price ? `${item.price * e.amount}` : '-') ]);
const weight = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.weight }], [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.weight }), () => item.weight ? `${item.weight * e.amount}` : '-') ]);
return foldable(() => [
markdown(getText(item.description)),
div('flex flex-row justify-center gap-1', [
button(text('Offrir'), () => {
}, 'px-2 text-sm h-5 box-content'),
button(icon('radix-icons:minus', { width: 12, height: 12 }), () => {
const idx = items.findIndex(_e => _e === e);
if(idx === -1) return;
items[idx]!.amount--;
if(items[idx]!.amount <= 0) items.splice(idx, 1);
this.saveVariables();
}, 'p-1'),
button(icon('radix-icons:plus', { width: 12, height: 12 }), () => {
const idx = items.findIndex(_e => _e === e);
if(idx === -1) return;
if(item.equippable) items.push(stateFactory(item));
else if(items.find(_e => _e === e)) items.find(_e => _e === e)!.amount++;
else items.push(stateFactory(item));
this.saveVariables();
}, 'p-1')
]) ], [div('flex flex-row justify-between', [
div('flex flex-row items-center gap-4', [div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]),]),
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [
e.amount > 1 && !!item.price ? tooltip(price, `Prix unitaire: ${item.price}`, 'bottom') : price,
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('radix-icons:cross-2', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => e.amount ?? '-') ]),
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'text-red': !!item.capacity && itempower() > item.capacity }), () => item.capacity ? `${itempower()}/${item.capacity ?? 0}` : '-') ]),
e.amount > 1 && !!item.weight ? tooltip(weight, `Poids unitaire: ${item.weight}`, 'bottom') : weight,
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => item.charge ? `${item.charge}` : '-') ]),
]),
])], { open: false, class: { icon: 'px-2', container: 'p-1 gap-2', content: 'px-4 pb-1 flex flex-col' } })
}})
])
];
}
} }

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,11 @@
import type { Ability, Alignment, ArmorConfig, Character, CharacterConfig, CharacterVariables, CompiledCharacter, DamageType, FeatureItem, ItemConfig, ItemState, Level, MainStat, Resistance, SpellElement, SpellType, TrainingLevel, WeaponConfig, WeaponType } from "~/types/character"; import type { Ability, Alignment, ArmorConfig, ArmorState, Character, CharacterConfig, CharacterVariables, CompiledCharacter, DamageType, EnchantementConfig, FeatureItem, ItemConfig, ItemState, Level, MainStat, MundaneState, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, WeaponConfig, WeaponState, WeaponType, WondrousState } 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, checkbox, floater, foldable, input, loading, multiselect, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util"; import { button, buttongroup, checkbox, floater, foldable, input, loading, multiselect, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util";
import { div, dom, icon, span, text, type RedrawableHTML } from "#shared/dom.util"; import { div, dom, icon, span, text, type RedrawableHTML } from "#shared/dom.util";
import { followermenu, fullblocker, tooltip } from "#shared/floating.util"; import { followermenu, fullblocker, tooltip } from "#shared/floating.util";
import { clamp, deepEquals } from "#shared/general.util"; import { clamp } from "#shared/general.util";
import markdown from "#shared/markdown.util"; import markdown from "#shared/markdown.util";
import { getText } from "#shared/i18n"; import { getText } from "#shared/i18n";
import type { User } from "~/types/auth"; import type { User } from "~/types/auth";
@ -18,7 +18,7 @@ const config = characterConfig as CharacterConfig;
export const MAIN_STATS = ["strength","dexterity","constitution","intelligence","curiosity","charisma","psyche"] as const; export const MAIN_STATS = ["strength","dexterity","constitution","intelligence","curiosity","charisma","psyche"] as const;
export const ABILITIES = ["athletics","acrobatics","intimidation","sleightofhand","stealth","survival","investigation","history","religion","arcana","understanding","perception","performance","medecine","persuasion","animalhandling","deception"] as const; export const ABILITIES = ["athletics","acrobatics","intimidation","sleightofhand","stealth","survival","investigation","history","religion","arcana","understanding","perception","performance","medecine","persuasion","animalhandling","deception"] as const;
export const LEVELS = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20] as const; export const LEVELS = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20] as const;
export const TRAINING_LEVELS = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15] as const; export const TRAINING_LEVELS = [0,1,2,3,4,5,6,7,8,9,10,11,12] as const;
export const SPELL_TYPES = ["precision","knowledge","instinct","arts"] as const; export const SPELL_TYPES = ["precision","knowledge","instinct","arts"] as const;
export const CATEGORIES = ["action","reaction","freeaction","misc"] as const; export const CATEGORIES = ["action","reaction","freeaction","misc"] as const;
export const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","nature","light","psyche"] as const; export const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","nature","light","psyche"] as const;
@ -52,7 +52,7 @@ export const defaultCharacter: Character = {
owner: -1, owner: -1,
visibility: "private", visibility: "private",
}; };
const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (character: Character) => ({ const defaultCompiledCharacter = (character: Character) => ({
id: character.id, id: character.id,
owner: character.owner, owner: character.owner,
username: character.username, username: character.username,
@ -63,8 +63,6 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
modifier: MAIN_STATS.reduce((p, v) => { p[v] = 0; return p; }, {} as Record<MainStat, number>), modifier: MAIN_STATS.reduce((p, v) => { p[v] = 0; return p; }, {} as Record<MainStat, number>),
level: character.level, level: character.level,
variables: character.variables, variables: character.variables,
action: 0,
reaction: 0,
exhaust: 0, exhaust: 0,
itempower: 0, itempower: 0,
features: { features: {
@ -123,6 +121,12 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
bonus: { bonus: {
abilities: {}, abilities: {},
defense: {}, defense: {},
spells: {
elements: {},
type: {},
rank: {},
},
weapon: {}
}, },
resistance: {}, resistance: {},
initiative: 0, initiative: 0,
@ -134,9 +138,21 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
passive: [], passive: [],
spells: [], spells: [],
}, },
aspect: "", aspect: {
id: character.aspect ?? "",
duration: 0,
amount: 0,
bonus: 0,
tier: 0,
},
advantages: [],
craft: {
bonus: 0,
level: 0,
prototype: false,
},
notes: Object.assign({ public: '', private: '' }, character.notes), notes: Object.assign({ public: '', private: '' }, character.notes),
}); } as CompiledCharacter);
export const mainStatTexts: Record<MainStat, string> = { export const mainStatTexts: Record<MainStat, string> = {
"strength": "Force", "strength": "Force",
"dexterity": "Dextérité", "dexterity": "Dextérité",
@ -344,7 +360,15 @@ export class CharacterCompiler
get armor() get armor()
{ {
const armors = this._character.variables.items.filter(e => e.equipped && config.items[e.id]?.category === 'armor'); const armors = this._character.variables.items.filter(e => e.equipped && config.items[e.id]?.category === 'armor');
return armors.length > 0 ? armors.map(e => ({ max: (config.items[e.id] as ArmorConfig).health, current: (config.items[e.id] as ArmorConfig).health - e.state })).reduce((p, v) => { p.max += v.max; p.current += v.current; return p; }, { max: 0, current: 0 }) : undefined; return armors.length > 0 ? armors.map(e => ({ max: (config.items[e.id] as ArmorConfig).health, current: (config.items[e.id] as ArmorConfig).health - ((e.state as ArmorState)?.health ?? 0) })).reduce((p, v) => { p.max += v.max; p.current += v.current; return p; }, { max: 0, current: 0 }) : undefined;
}
get weight()
{
return this._character.variables.items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0);
}
get power()
{
return this._character.variables.items.filter(e => config.items[e.id]?.equippable && e.equipped).reduce((p, v) => p + ((config.items[v.id]?.powercost ?? 0) + (v.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0) * v.amount), 0);
} }
parse(text: string): string parse(text: string): string
@ -889,7 +913,7 @@ class LevelPicker extends BuilderTab
return dom("div", { class: ["flex border border-light-50 dark:border-dark-50 px-4 py-2 w-[400px] relative", { 'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer': (level[0] as any as Level) <= this._builder.character.level, '!border-accent-blue bg-accent-blue bg-opacity-20': this._builder.character.leveling[level[0] as any as Level] === j }], listeners: { click: e => { return dom("div", { class: ["flex border border-light-50 dark:border-dark-50 px-4 py-2 w-[400px] relative", { 'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer': (level[0] as any as Level) <= this._builder.character.level, '!border-accent-blue bg-accent-blue bg-opacity-20': this._builder.character.leveling[level[0] as any as Level] === j }], listeners: { click: e => {
this._builder.toggleLevelOption(parseInt(level[0]) as Level, j); this._builder.toggleLevelOption(parseInt(level[0]) as Level, j);
this.update(); this.update();
}}}, [ dom('span', { class: "text-wrap whitespace-pre", text: config.features[option]!.description }), choice ]); }}}, [ dom('span', { class: "text-wrap whitespace-pre", text: getText(config.features[option]!.description) }), choice ]);
})) }))
]); ]);
@ -970,7 +994,7 @@ class TrainingPicker extends BuilderTab
return dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50 relative"], listeners: { click: e => { return dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50 relative"], listeners: { click: e => {
this._builder.toggleTrainingOption(stat, parseInt(level[0]) as TrainingLevel, j); this._builder.toggleTrainingOption(stat, parseInt(level[0]) as TrainingLevel, j);
this.update(); this.update();
}}}, [ markdown(config.features[option]!.description, undefined, { tags: { a: preview } }), choice ]); }}}, [ markdown(getText(config.features[option]!.description), undefined, { tags: { a: preview } }), choice ]);
})) }))
]); ]);
} }
@ -1259,7 +1283,7 @@ export const rarityText: Record<Rarity, string> = {
'rare': 'Rare', 'rare': 'Rare',
'legendary': 'Légendaire' 'legendary': 'Légendaire'
}; };
const subnameFactory = (item: ItemConfig, state?: ItemState): string[] => { export const subnameFactory = (item: ItemConfig, state?: ItemState): string[] => {
let result = []; let result = [];
switch(item.category) switch(item.category)
{ {
@ -1281,12 +1305,21 @@ const subnameFactory = (item: ItemConfig, state?: ItemState): string[] => {
return result; return result;
} }
const stateFactory = (item: ItemConfig) => { export const stateFactory = (item: ItemConfig) => {
const state = { id: item.id, amount: 1, charges: item.charge, enchantments: [], equipped: item.equippable ? false : undefined } as ItemState; const state = { id: item.id, amount: 1, charges: item.charge, enchantments: [], equipped: item.equippable ? false : undefined } as ItemState;
switch(item.category) switch(item.category)
{ {
case 'armor': case 'armor':
state.state = 0; state.state = { health: 0 } as ArmorState;
break;
case 'mundane':
state.state = { } as MundaneState;
break;
case 'weapon':
state.state = { attack: 0, hit: 0 } as WeaponState;
break;
case 'wondrous':
state.state = { } as WondrousState;
break; break;
default: break; default: break;
} }
@ -1313,7 +1346,7 @@ export class CharacterSheet
if(character.campaign) if(character.campaign)
{ {
this.ws = new Socket(`/ws/campaign/${character.campaign}`, true); /* this.ws = new Socket(`/ws/campaign/${character.campaign}`, true);
this.ws.handleMessage('SYNC', () => { this.ws.handleMessage('SYNC', () => {
useRequestFetch()(`/api/character/${id}`).then(character => { useRequestFetch()(`/api/character/${id}`).then(character => {
@ -1326,9 +1359,9 @@ export class CharacterSheet
}); });
}) })
this.ws.handleMessage<{ action: 'set' | 'add' | 'remove', key: keyof CharacterVariables, value: any }>('VARIABLE', (variable) => { this.ws.handleMessage<{ action: 'set' | 'add' | 'remove', key: keyof CharacterVariables, value: any }>('VARIABLE', (variable) => {
/* const prop = this.character?.character.variables[variable.key]; const prop = this.character!.character.variables[variable.key];
if(variable.action === 'set') if(variable.action === 'set')
this.character?.variable(variable.key, variable.value, false); this.character!.character.variables[variable.key] = variable.value;
else if(Array.isArray(prop)) else if(Array.isArray(prop))
{ {
if(variable.action === 'add') if(variable.action === 'add')
@ -1338,9 +1371,8 @@ export class CharacterSheet
const idx = prop.findIndex(e => deepEquals(e, variable.value)); const idx = prop.findIndex(e => deepEquals(e, variable.value));
if(idx !== -1) prop.splice(idx, 1); if(idx !== -1) prop.splice(idx, 1);
} }
this.character?.variable(variable.key, prop, false); }
} */ }) */
})
} }
document.title = `d[any] - ${character.name}`; document.title = `d[any] - ${character.name}`;
@ -1662,9 +1694,9 @@ export class CharacterSheet
switch(preference.sort) switch(preference.sort)
{ {
case 'rank': return spells.sort((a, b) => (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0) || SPELL_ELEMENTS.indexOf(config.spells[a]?.elements[0]!) - SPELL_ELEMENTS.indexOf(config.spells[b]?.elements[0]!)); case 'rank': return spells.sort((a, b) => ((config.spells[a] as SpellConfig)?.rank ?? 0) - ((config.spells[b] as SpellConfig)?.rank ?? 0) || SPELL_ELEMENTS.indexOf((config.spells[a] as SpellConfig)?.elements[0]!) - SPELL_ELEMENTS.indexOf((config.spells[b] as SpellConfig)?.elements[0]!));
case 'type': return spells.sort((a, b) => config.spells[a]?.type.localeCompare(config.spells[b]?.type ?? '') || (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0)); case 'type': return spells.sort((a, b) => (config.spells[a] as SpellConfig)?.type.localeCompare((config.spells[b] as SpellConfig)?.type ?? '') || ((config.spells[a] as SpellConfig)?.rank ?? 0) - ((config.spells[b] as SpellConfig)?.rank ?? 0));
case 'element': return spells.sort((a, b) => SPELL_ELEMENTS.indexOf(config.spells[a]?.elements[0]!) - SPELL_ELEMENTS.indexOf(config.spells[b]?.elements[0]!) || (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0)); case 'element': return spells.sort((a, b) => SPELL_ELEMENTS.indexOf((config.spells[a] as SpellConfig)?.elements[0]!) - SPELL_ELEMENTS.indexOf((config.spells[b] as SpellConfig)?.elements[0]!) || ((config.spells[a] as SpellConfig)?.rank ?? 0) - ((config.spells[b] as SpellConfig)?.rank ?? 0));
default: return spells; default: return spells;
} }
}; };
@ -1684,7 +1716,7 @@ export class CharacterSheet
]) ])
]), ]),
div('flex flex-col gap-2', { render: e => { div('flex flex-col gap-2', { render: e => {
const spell = config.spells[e]; const spell = config.spells[e] as SpellConfig | undefined;
if(!spell) if(!spell)
return; return;
@ -1704,10 +1736,11 @@ export class CharacterSheet
spellPanel(character: CompiledCharacter) spellPanel(character: CompiledCharacter)
{ {
const availableSpells = Object.values(config.spells).filter(spell => { const availableSpells = Object.values(config.spells).filter(spell => {
if (spell.rank === 4) return false; if(spell.type === 'arts') return false;
if (character.spellranks[spell.type] < spell.rank) return false; if(spell.rank === 4) return false;
if(character.spellranks[spell.type] < spell.rank) return false;
return true; return true;
}); }) as SpellConfig[];
const spells = character.variables.spells; const spells = character.variables.spells;
const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [ const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [
@ -1766,23 +1799,33 @@ export class CharacterSheet
itemsTab(character: CompiledCharacter) itemsTab(character: CompiledCharacter)
{ {
const items = character.variables.items; const items = character.variables.items;
const power = () => items.filter(e => config.items[e.id]?.equippable && e.equipped).reduce((p, v) => p + ((config.items[v.id]?.powercost ?? 0) + (v.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0) * v.amount), 0);
const weight = () => items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0);
const panel = this.itemsPanel(character); const panel = this.itemsPanel(character);
const enchant = this.enchantPanel(character);
const money = {
readonly: dom('div', { listeners: { click: () => { money.readonly.replaceWith(money.edit); money.edit.focus(); } }, class: 'cursor-pointer border border-transparent hover:border-light-40 dark:hover:border-dark-40 px-2 py-px flex flex-row gap-1 items-center' }, [ span('text-lg font-bold', () => character.variables.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]),
edit: numberpicker({ defaultValue: character.variables.money, change: v => { character.variables.money = v; this.character?.saveVariables(); money.edit.replaceWith(money.readonly); }, blur: v => { character.variables.money = v; this.character?.saveVariables(); money.edit.replaceWith(money.readonly); }, min: 0, class: 'w-24' }),
};
return [ return [
div('flex flex-col gap-2', [ div('flex flex-col gap-2', [
div('flex flex-row justify-end items-center gap-8', [ div('flex flex-row justify-between items-center', [
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': weight() > character.itempower }], text: () => `Poids total: ${weight()}/${character.itempower}` }), div('flex flex-row justify-end items-center gap-8', [
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': power() > (character.capacity === false ? 0 : character.capacity) }], text: () => `Puissance magique: ${power()}/${character.capacity}` }), div('flex flex-row gap-1 items-center', [ span('italic text-sm', 'Argent'), money.readonly ]),
button(text('Modifier'), () => panel.show(), 'py-1 px-4'), ]),
div('flex flex-row justify-end items-center gap-8', [
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.character!.power > character.itempower }], text: () => `Puissance magique: ${this.character!.power}/${character.itempower}` }),
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.character!.weight > (character.capacity === false ? 0 : character.capacity) }], text: () => `Poids total: ${this.character!.weight}/${character.capacity}` }),
button(text('Modifier'), () => panel.show(), 'py-1 px-4'),
]),
]), ]),
div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: character.variables.items, render: e => { div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: character.variables.items, render: e => {
const item = config.items[e.id]; const item = config.items[e.id];
if(!item) return; if(!item) return;
const itempower = () => (item.powercost ?? 0) + (e.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0);
const price = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.price }], [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.price }), () => item.price ? `${item.price * e.amount}` : '-') ]); const price = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.price }], [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.price }), () => item.price ? `${item.price * e.amount}` : '-') ]);
const weight = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.weight }], [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.weight }), () => item.weight ? `${item.weight * e.amount}` : '-') ]); const weight = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.weight }], [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.weight }), () => item.weight ? `${item.weight * e.amount}` : '-') ]);
return foldable(() => [ return foldable(() => [
@ -1810,11 +1853,17 @@ export class CharacterSheet
this.character?.saveVariables(); this.character?.saveVariables();
}, 'p-1'), }, 'p-1'),
button(text("Enchanter"), () => {
enchant.show(e);
}, 'px-2 text-sm h-5 box-content'),
]) ], [div('flex flex-row justify-between', [ ]) ], [div('flex flex-row justify-between', [
div('flex flex-row items-center gap-4', [ div('flex flex-row items-center gap-4', [
item.equippable ? checkbox({ defaultValue: e.equipped, change: v => { item.equippable ? checkbox({ defaultValue: e.equipped, change: v => {
e.equipped = v; e.equipped = v;
//TODO: Toggle effect and enchants
this.character?.saveVariables(); this.character?.saveVariables();
}, class: { container: '!w-5 !h-5' } }) : undefined, }, class: { container: '!w-5 !h-5' } }) : undefined,
div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]), div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]),
@ -1822,7 +1871,7 @@ export class CharacterSheet
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [ div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [
e.amount > 1 && !!item.price ? tooltip(price, `Prix unitaire: ${item.price}`, 'bottom') : price, e.amount > 1 && !!item.price ? tooltip(price, `Prix unitaire: ${item.price}`, 'bottom') : price,
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('radix-icons:cross-2', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => e.amount ?? '-') ]), div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('radix-icons:cross-2', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => e.amount ?? '-') ]),
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => item.powercost || item.capacity ? `${item.powercost ?? 0}/${item.capacity ?? 0}` : '-') ]), div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'text-red': !!item.capacity && itempower() > item.capacity }), () => item.capacity ? `${itempower()}/${item.capacity ?? 0}` : '-') ]),
e.amount > 1 && !!item.weight ? tooltip(weight, `Poids unitaire: ${item.weight}`, 'bottom') : weight, e.amount > 1 && !!item.weight ? tooltip(weight, `Poids unitaire: ${item.weight}`, 'bottom') : weight,
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => item.charge ? `${item.charge}` : '-') ]), div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => item.charge ? `${item.charge}` : '-') ]),
]), ]),
@ -1843,10 +1892,13 @@ export class CharacterSheet
const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [ const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [
div("flex flex-row justify-between items-center mb-4", [ div("flex flex-row justify-between items-center mb-4", [
dom("h2", { class: "text-xl font-bold", text: "Gestion de l'inventaire" }), dom("h2", { class: "text-xl font-bold", text: "Gestion de l'inventaire" }),
div('flex flex-row gap-4 items-center', [ tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => { div('flex flex-row gap-8 items-center justify-end', [
setTimeout(blocker.close, 150); dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.character!.weight > (character.capacity === false ? 0 : character.capacity) }], text: () => `Poids total: ${this.character!.weight}/${character.capacity}` }),
container.setAttribute('data-state', 'inactive'); tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => {
}, "p-1"), "Fermer", "left") ]) setTimeout(blocker.close, 150);
container.setAttribute('data-state', 'inactive');
}, "p-1"), "Fermer", "left")
])
]), ]),
div('flex flex-row items-center gap-4', [ div('flex flex-row items-center gap-4', [
div('flex flex-row gap-2 items-center', [ text('Catégorie'), multiselect(Object.keys(categoryText).map(e => ({ text: categoryText[e as Category], value: e as Category })), { defaultValue: filters.category, change: v => filters.category = v, class: { container: 'w-40' } }) ]), div('flex flex-row gap-2 items-center', [ text('Catégorie'), multiselect(Object.keys(categoryText).map(e => ({ text: categoryText[e as Category], value: e as Category })), { defaultValue: filters.category, change: v => filters.category = v, class: { container: 'w-40' } }) ]),
@ -1894,4 +1946,66 @@ export class CharacterSheet
container.setAttribute('data-state', 'inactive'); container.setAttribute('data-state', 'inactive');
}}; }};
} }
enchantPanel(character: CompiledCharacter)
{
const current = reactive({
item: undefined as ItemState | undefined,
});
const restrict = (enchant: EnchantementConfig, id?: string) => {
if(!id) return true;
const item = config.items[id]!;
if(enchant.restrictions?.type && item.category === enchant.restrictions.type)
{
switch(item.category)
{
case 'armor':
return enchant.restrictions.subtype ? item.type === enchant.restrictions.subtype : true;
case 'weapon':
return enchant.restrictions.subtype ? item.type.includes(enchant.restrictions.subtype) : true;
default: return true;
}
}
return true;
}
const itempower = () => current.item && config.items[current.item.id] !== undefined ? ((config.items[current.item.id]!.powercost ?? 0) + (current.item.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0)) : 0;
const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [
div("flex flex-row justify-between items-center mb-4", [
dom("h2", { class: "text-xl font-bold", text: "Enchantements" }),
div('flex flex-row gap-8 items-center justify-end', [
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': current.item && config.items[current.item.id] !== undefined ? itempower() > (config.items[current.item.id]!.capacity ?? 0) : false }], text: () => `Puissance de l'objet: ${current.item && config.items[current.item.id] !== undefined ? itempower() : false}/${current.item ? (config.items[current.item.id]!.capacity ?? 0) : 0}` }),
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.character!.power > character!.itempower }], text: () => `Puissance du personnage: ${this.character!.power}/${character.itempower}` }),
tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => {
setTimeout(blocker.close, 150);
container.setAttribute('data-state', 'inactive');
}, "p-1"), "Fermer", "left")
])
]),
div('grid grid-cols-1 -my-2 overflow-y-auto gap-1', { list: () => Object.values(config.enchantments).filter(e => restrict(e, current.item?.id)), render: (enchant) => foldable(() => [ markdown(getText(enchant.description)) ], [div('flex flex-row justify-between', [
div('flex flex-row items-center gap-4', [ span('text-lg', enchant.name) ]),
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [
span('italic text-sm', `Puissance magique: ${enchant.power}`),
button(icon('radix-icons:plus', { width: 16, height: 16 }), () => {
current.item!.enchantments?.push(enchant.id);
this.character?.saveVariables();
}, 'p-1 !border-solid !border-r'),
]),
])], { open: false, class: { icon: 'px-2', container: 'border border-light-35 dark:border-dark-35 p-1 gap-2', content: 'px-2 pb-1' } })
}),
]);
const blocker = fullblocker([ container ], { closeWhenOutside: true, open: false });
return { show: (item: ItemState) => {
setTimeout(() => container.setAttribute('data-state', 'active'), 1);
current.item = item;
blocker.open();
}, hide: () => {
setTimeout(blocker.close, 150);
container.setAttribute('data-state', 'inactive');
}};
}
} }

View File

@ -223,7 +223,7 @@ export function multiselect<T extends NonNullable<any>>(options: Array<{ text: s
}) })
return select; return select;
} }
export function combobox<T extends NonNullable<any>>(options: Option<T>[], settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' }): RedrawableHTML export function combobox<T extends NonNullable<any>>(options: Option<T>[], settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' })
{ {
let context: { container: RedrawableHTML, content: NodeChildren, close: () => void }; let context: { container: RedrawableHTML, content: NodeChildren, close: () => void };
let selected = true, tree: StoredOption<T>[] = []; let selected = true, tree: StoredOption<T>[] = [];
@ -295,12 +295,7 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
} }
else else
{ {
return { item: option, dom: dom('div', { listeners: { click: (_e) => { return { item: option, dom: dom('div', { listeners: { click: (_e) => { container.value = option.value as T; !_e.ctrlKey && hide(); }, mouseenter: () => focus(option.value) }, class: ['data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ option?.render ? option?.render() : text(option.text) ]) };
select.value = option.text;
settings?.change && settings?.change(option.value as T);
selected = true;
!_e.ctrlKey && hide();
}, mouseenter: () => focus(option.value) }, class: ['data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ option?.render ? option?.render() : text(option.text) ]) };
} }
} }
const filter = (value: string, option?: StoredOption<T>): StoredOption<T>[] => { const filter = (value: string, option?: StoredOption<T>): StoredOption<T>[] => {
@ -350,9 +345,9 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
default: return; default: return;
} }
} }, attributes: { type: 'text', }, class: 'flex-1 outline-none px-3 leading-none appearance-none py-1 bg-light-25 dark:bg-dark-25 disabled:bg-light-20 dark:disabled:bg-dark-20' }); } }, attributes: { type: 'text', }, class: 'flex-1 outline-none px-3 leading-none appearance-none py-1 bg-light-25 dark:bg-dark-25 disabled:bg-light-20 dark:disabled:bg-dark-20' });
settings?.defaultValue && Tree.each(options, 'value', (item) => { if(item.value === settings?.defaultValue) select.value = item.text });
const container = dom('label', { class: ['inline-flex outline-none px-3 items-center justify-between text-sm font-semibold leading-none gap-1 bg-light-25 dark:bg-dark-25 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark:data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ select, icon('radix-icons:caret-down') ]); const container = dom('label', { class: ['inline-flex outline-none px-3 items-center justify-between text-sm font-semibold leading-none gap-1 bg-light-25 dark:bg-dark-25 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark:data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ select, icon('radix-icons:caret-down') ]) as HTMLLabelElement & { disabled: boolean, value: T | undefined };
let value: T | undefined = undefined;
Object.defineProperty(container, 'disabled', { Object.defineProperty(container, 'disabled', {
get: () => disabled, get: () => disabled,
@ -362,6 +357,26 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
select.toggleAttribute('disabled', disabled); select.toggleAttribute('disabled', disabled);
}, },
}) })
Object.defineProperty(container, 'value', {
get: () => value,
set: (v: T | undefined) => {
select.value = '';
if(v !== undefined)
{
Tree.each(options, 'value', (item) => { if(item.value === v) select.value = item.text });
if(select.value === '') select.value = v as string;
else selected = true;
}
settings?.change && value !== v && settings?.change(v as T);
value = v;
},
});
if(settings?.defaultValue)
{
Tree.each(options, 'value', (item) => { if(item.value === settings.defaultValue) select.value = item.text });
value = settings.defaultValue;
}
return container; return container;
} }
export function input(type: 'text' | 'number' | 'email' | 'password' | 'tel', settings?: { defaultValue?: string, change?: (value: string) => void, input?: (value: string) => void | boolean, focus?: () => void, blur?: () => void, class?: Class, disabled?: boolean, placeholder?: string }): HTMLInputElement export function input(type: 'text' | 'number' | 'email' | 'password' | 'tel', settings?: { defaultValue?: string, change?: (value: string) => void, input?: (value: string) => void | boolean, focus?: () => void, blur?: () => void, class?: Class, disabled?: boolean, placeholder?: string }): HTMLInputElement
@ -379,7 +394,7 @@ export function input(type: 'text' | 'number' | 'email' | 'password' | 'tel', se
return input; return input;
} }
export function numberpicker(settings?: { defaultValue?: number, change?: (value: number) => void, input?: (value: number) => void, focus?: () => void, blur?: () => void, class?: Class, min?: number, max?: number, disabled?: boolean }): HTMLInputElement export function numberpicker(settings?: { defaultValue?: number, change?: (value: number) => void, input?: (value: number) => void, focus?: (value: number) => void, blur?: (value: number) => void, class?: Class, min?: number, max?: number, disabled?: boolean }): HTMLInputElement
{ {
let storedValue = settings?.defaultValue ?? 0; let storedValue = settings?.defaultValue ?? 0;
const validateAndChange = (value: number) => { const validateAndChange = (value: number) => {
@ -417,13 +432,16 @@ export function numberpicker(settings?: { defaultValue?: number, change?: (value
case "PageDown": case "PageDown":
settings?.min && validateAndChange(settings.min) && settings?.input && settings.input(storedValue); settings?.min && validateAndChange(settings.min) && settings?.input && settings.input(storedValue);
break; break;
case "Enter":
settings?.change && settings.change(storedValue);
break;
default: default:
return; return;
} }
}, },
change: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.change && settings.change(storedValue), change: () => settings?.change && settings.change(storedValue),
focus: () => settings?.focus && settings.focus(), focus: () => settings?.focus && settings.focus(storedValue),
blur: () => settings?.blur && settings.blur(), blur: () => settings?.blur && settings.blur(storedValue),
}}); }});
if(settings?.defaultValue !== undefined) field.value = storedValue.toString(10); if(settings?.defaultValue !== undefined) field.value = storedValue.toString(10);
@ -441,7 +459,6 @@ export function foldable(content: Reactive<NodeChildren>, title: NodeChildren, s
lazyContent = (typeof content === 'function' ? content() : content)?.map(e => typeof e ==='function' ? e() : e); lazyContent = (typeof content === 'function' ? content() : content)?.map(e => typeof e ==='function' ? e() : e);
lazyContent && contentContainer.replaceChildren(...lazyContent.map(e => typeof e ==='function' ? e() : e).filter(e => !!e)); lazyContent && contentContainer.replaceChildren(...lazyContent.map(e => typeof e ==='function' ? e() : e).filter(e => !!e));
} }
else contentContainer.update && contentContainer.update(true);
} }
} }
const contentContainer = div(['hidden group-data-[active]:flex', settings?.class?.content]); const contentContainer = div(['hidden group-data-[active]:flex', settings?.class?.content]);

View File

@ -1,11 +1,11 @@
import { safeDestr as parse } from 'destr'; import { safeDestr as parse } from 'destr';
import { Canvas, CanvasEditor } from "#shared/canvas.util"; import { Canvas, CanvasEditor } from "#shared/canvas.util";
import render from "#shared/markdown.util"; import render, { renderMDAsText } from "#shared/markdown.util";
import { confirm, contextmenu, tooltip } from "#shared/floating.util"; import { confirm, contextmenu, tooltip } from "#shared/floating.util";
import { cancelPropagation, dom, icon, text, type Node, type RedrawableHTML } from "#shared/dom.util"; import { cancelPropagation, dom, icon, text, type Node, type RedrawableHTML } from "#shared/dom.util";
import { async, loading } from "#shared/components.util"; import { loading } from "#shared/components.util";
import prose, { h1, h2 } from "#shared/proses"; import prose, { h1, h2 } from "#shared/proses";
import { getID, parsePath } from '#shared/general.util'; import { getID, lerp, parsePath } from '#shared/general.util';
import { TreeDOM, type Recursive } from '#shared/tree'; import { TreeDOM, type Recursive } from '#shared/tree';
import { History } from '#shared/history.util'; import { History } from '#shared/history.util';
import { MarkdownEditor } from '#shared/editor.util'; import { MarkdownEditor } from '#shared/editor.util';
@ -16,6 +16,9 @@ import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import type { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types'; import type { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';
import type { CharacterConfig, FeatureID, MainStat, TrainingLevel } from '~/types/character';
import { getText } from './i18n';
import { mainStatTexts } from './character.util';
export type FileType = keyof ContentMap; export type FileType = keyof ContentMap;
export interface ContentMap export interface ContentMap
@ -583,7 +586,7 @@ export class Editor
selected?: Recursive<LocalContent & { element?: RedrawableHTML }>; selected?: Recursive<LocalContent & { element?: RedrawableHTML }>;
private instruction: RedrawableHTML; private instruction: RedrawableHTML;
private cleanup!: CleanupFn; private cleanup?: CleanupFn;
private history: History; private history: History;
@ -917,7 +920,7 @@ export class Editor
} }
unmount() unmount()
{ {
this.cleanup(); this.cleanup && this.cleanup();
} }
} }
@ -932,3 +935,49 @@ export function getPath(item: any): string
else else
return parsePath(item.title) ?? item.path; return parsePath(item.title) ?? item.path;
} }
/* export function buildSpellMD()
{
const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","nature","light","psyche"];
const SPELL_TYPE_TEXTS = { "instinct": "Instinct", "knowledge": "Savoir", "precision": "Précision", "arts": "Oeuvres" };
const SPELL_ELEMENTS_TEXTS = { 'fire': 'feu', 'ice': 'glace', 'thunder': 'foudre', 'earth': 'terre', 'arcana': 'arcane', 'air': 'air', 'nature': 'nature', 'light': 'lumiere', 'psyche': 'psy' };
const SPELL_SPEED_TEXTS = { 'action': 'action', 'reaction': 'réaction', 'number': '%1 minute(s)' };
return Object.values(config.spells).sort((a, b) => a.rank - b.rank || SPELL_ELEMENTS.indexOf(a.type) - SPELL_ELEMENTS.indexOf(b.type)).map(e => `- ${e.name} ${e.elements.map(el => '#' + SPELL_ELEMENTS_TEXTS[el]).join(' + ')} ${SPELL_TYPE_TEXTS[e.type]} (${e.cost} mana, ${typeof e.speed === 'number' ? SPELL_SPEED_TEXTS['number'].replace('%1', e.speed.toString()).replace('(s)', e.speed === 1 ? '' : 's') : SPELL_SPEED_TEXTS[e.speed]}${e.concentration ? ', [[1. Magie#La concentration|concentration]]' : ''}, ${e.range === 'personnal' ? 'Personnel' : e.range === 0 ? 'Toucher' : e.range + ' cases'})\nTags: ${e.tags?.join(', ') ?? '-'}\n\t${e.description.endsWith('.') ? e.description : e.description + '.'}`).join('\n\n');
}
export function buildTrainingTree()
{
const getLocalID = () => { for (var t = [], n = 0; n < 16; n++) t.push((16 * Math.random() | 0).toString(16)); return t.join(""); }, colors = ["4", "1", "6", "6"], WIDTH = 448, HEIGHT = (lines: number) => lines === 0 ? 64 : (40 + (24 * lines)), PADDING_X = 48, PADDING_Y = 80;
const _tree = {nodes: [], edges: [] } as any;
let _maxX = 0,_minY = 0,_maxY = 0;
const tree = (branch: Record<TrainingLevel, FeatureID[]>, name: string, color: string) => {
let previousLevel = {} as any, maxAmount = Object.values(branch).reduce((p, v) => Math.max(p, v.length), 0), minX = _maxX + PADDING_X, maxX = minX + (PADDING_X + WIDTH) * maxAmount + PADDING_X, minY = _minY, maxY = _minY;
Object.entries(branch).forEach(([i, e]: [string, FeatureID[]]) => {
const nodes = e.map((_e, _i) => ({ type: 'text', id: getLocalID(), text: getText(config.features[_e]?.description), color: colors[_i] ?? undefined, x: lerp(minX, maxX - PADDING_X - PADDING_X - WIDTH, (_i + ((maxAmount - e.length) / 2)) / (maxAmount - 1)), y: maxY, width: WIDTH, height: HEIGHT(Math.ceil(renderMDAsText(getText(config.features[_e]?.description)).length / 52)) }));
maxY += nodes.map(e => e.height).reduce((p, v) => Math.max(p, v), 0) + PADDING_Y;
_tree.nodes.push(...nodes);
nodes.forEach((_e, _i) => { if(previousLevel[_i]) _tree.edges.push({id: getLocalID(), fromNode: previousLevel[_i].id, fromSide: "bottom", toNode: _e.id, toSide: "top", color: colors[_i] ?? undefined }); else if(previousLevel[0]) _tree.edges.push({id: getLocalID(), fromNode: previousLevel[0].id, fromSide: "bottom", toNode: _e.id, toSide: "top", color: colors[_i] ?? undefined }); });
previousLevel = nodes;
});
_tree.nodes.push({ type: 'group', label: name, x: minX - PADDING_X, y: minY - PADDING_Y, width: maxX - minX, height: maxY - minY + PADDING_Y, color: color })
_maxX = Math.max(_maxX, maxX);
_maxY = Math.max(_maxY, maxY + PADDING_Y);
};
tree(config.training.strength, 'Force', '1');
tree(config.training.dexterity, 'Dextérité', '4');
tree(config.training.constitution, 'Constitution', '2');
tree(config.training.intelligence, 'Intelligence', '5');
tree(config.training.curiosity, 'Curiosité', '3');
tree(config.training.charisma, 'Charisme', '#fe39ee');
tree(config.training.psyche, 'Psyché', '6');
return _tree;
}
export function buildTrainingFile()
{
return Object.entries(config.training).map(e => {
return `# ${mainStatTexts[e[0] as MainStat]}\n` + Object.entries(e[1]).map(_e => `## Niveau ${_e[0]}\n` + _e[1].map(feature => renderMDAsText(getText(config.features[feature]!.description))).join('\nou\n')).join('\n');
}).join('\n');
} */

View File

@ -1,9 +1,9 @@
import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon'; import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon';
import { loading } from './components.util'; import { loading } from './components.util';
import { _defer, reactivity, type Proxy, type Reactive } from './reactive'; import { _defer, raw, reactivity, type Proxy, type Reactive } from './reactive';
export type RedrawableHTML = HTMLElement & { update?: (recursive: boolean) => void } export type RedrawableHTML = HTMLElement;
export type Node = HTMLElement & { update?: (recursive: boolean) => void } | SVGElement | Text | undefined; export type Node = HTMLElement | SVGElement | Text | undefined;
export type NodeChildren = Array<Reactive<Node>> | undefined; export type NodeChildren = Array<Reactive<Node>> | undefined;
export type Class = string | Array<Class> | Record<string, boolean> | undefined; export type Class = string | Array<Class> | Record<string, boolean> | undefined;
@ -27,13 +27,25 @@ export interface NodeProperties
}; };
} }
function append(dom: Element, children: Node | Node[] | undefined)
{
if(Array.isArray(children))
{
children.forEach(e => e && dom.appendChild(e));
}
else
{
children && dom.appendChild(children);
}
}
export const cancelPropagation = (e: Event) => e.stopImmediatePropagation(); export const cancelPropagation = (e: Event) => e.stopImmediatePropagation();
export function dom<T extends keyof HTMLElementTagNameMap>(tag: T, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[T]; export function dom<T extends keyof HTMLElementTagNameMap>(tag: T, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[T];
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: { render: (data: U) => Node, list?: Reactive<Array<U>> }): HTMLElementTagNameMap[T] & { array?: DOMList<U> }; export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: { render: (data: U) => Node | Node[], list?: Reactive<Array<U>>, fallback?: () => Node | Node[] }): HTMLElementTagNameMap[T] & { array?: DOMList<U> };
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U) => Node, list?: Reactive<Array<U>> }): HTMLElementTagNameMap[T] & { array?: DOMList<U> } export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U) => Node | Node[], list?: Reactive<Array<U>>, fallback?: () => Node | Node[] }): HTMLElementTagNameMap[T] & { array?: DOMList<U> }
{ {
const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList<U> }; const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList<U> };
const _cache = new Map<U, Node>(); const _cache = new Map<U, Node | Node[] | undefined>();
if(children) if(children)
{ {
@ -47,17 +59,36 @@ export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T
} }
else if(children.list !== undefined) else if(children.list !== undefined)
{ {
let fallback: Node | Node[] | undefined;
children.fallback && reactivity(children.fallback, (_fallback) => {
if(_fallback)
{
if(Array.isArray(fallback) && fallback.filter(e => !!e)[0] && fallback.filter(e => !!e)[0]!.parentElement === element)
element.replaceChildren(), append(element, _fallback);
else if(!Array.isArray(fallback) && fallback?.parentElement === element)
element.replaceChildren(), append(element, _fallback);
}
fallback = _fallback;
});
reactivity(children.list, (list) => { reactivity(children.list, (list) => {
element.replaceChildren(); element.replaceChildren();
list?.forEach(e => { if(list.length === 0)
let dom = _cache.get(e); {
if(!dom) fallback ??= children?.fallback ? children.fallback() : undefined;
{ append(element, fallback);
dom = children.render(e); }
_cache.set(e, dom); else
} {
dom && element.appendChild(dom); list.forEach(e => {
}); let child = _cache.get(e);
if(!child)
{
child = raw(children.render(e));
_cache.set(e, child);
}
append(element, child);
});
}
}) })
} }
} }
@ -113,13 +144,13 @@ export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T
return element; return element;
} }
export function div(cls?: Reactive<Class>, children?: NodeChildren): HTMLElementTagNameMap['div'] export function div(cls?: Reactive<Class>, children?: NodeChildren): HTMLElementTagNameMap['div']
export function div<U extends any>(cls?: Reactive<Class>, children?: { render: (data: U) => Node, list?: Reactive<Array<U>> }): HTMLElementTagNameMap['div'] & { array: DOMList<U> } export function div<U extends any>(cls?: Reactive<Class>, children?: { render: (data: U) => Node | Node[], list?: Reactive<Array<U>>, fallback?: () => Node | Node[] }): HTMLElementTagNameMap['div'] & { array: DOMList<U> }
export function div<U extends any>(cls?: Reactive<Class>, children?: NodeChildren | { render: (data: U) => Node, list?: Reactive<Array<U>> }): HTMLElementTagNameMap['div'] & { array?: DOMList<U> } export function div<U extends any>(cls?: Reactive<Class>, children?: NodeChildren | { render: (data: U) => Node | Node[], list?: Reactive<Array<U>>, fallback?: () => Node | Node[] }): HTMLElementTagNameMap['div'] & { array?: DOMList<U> }
{ {
//@ts-expect-error wtf is wrong here ??? //@ts-expect-error wtf is wrong here ???
return dom("div", { class: cls }, children); return dom("div", { class: cls }, children);
} }
export function span(cls?: Reactive<Class>, text?: Reactive<string | number | Text>): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void } export function span(cls?: Reactive<Class>, text?: Reactive<string | number | Text>): HTMLElementTagNameMap['span']
{ {
return dom("span", { class: cls, text: text }); return dom("span", { class: cls, text: text });
} }
@ -194,7 +225,7 @@ export interface IconProperties
class?: Class; class?: Class;
} }
const iconLoadingRegistry: Map<string, Promise<Required<IconifyIcon>> | null | undefined> = new Map(); const iconLoadingRegistry: Map<string, Promise<Required<IconifyIcon>> | null | undefined> = new Map();
export function icon(name: string, properties?: IconProperties) export function icon(name: Reactive<string>, properties?: IconProperties)
{ {
const element = dom('div', { class: properties?.class, style: properties?.style }); const element = dom('div', { class: properties?.class, style: properties?.style });
const build = (icon: IconifyIcon | null | undefined) => { const build = (icon: IconifyIcon | null | undefined) => {
@ -204,13 +235,15 @@ export function icon(name: string, properties?: IconProperties)
dom.innerHTML = built.body; dom.innerHTML = built.body;
element.replaceChildren(dom); element.replaceChildren(dom);
} }
if(!iconLoaded(name)) reactivity(name, (name) => {
{ if(!iconLoaded(name))
element.appendChild(loading('small')); {
if(!iconLoadingRegistry.has(name)) iconLoadingRegistry.set(name, loadIcon(name)); element.replaceChildren(loading('small'));
iconLoadingRegistry.get(name)?.then(build); if(!iconLoadingRegistry.has(name)) iconLoadingRegistry.set(name, loadIcon(name));
} iconLoadingRegistry.get(name)?.then(build);
else build(getIcon(name)); }
else build(getIcon(name));
})
return element; return element;
} }

View File

@ -1,18 +1,21 @@
import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, WidgetType, type DecorationSet } from '@codemirror/view'; import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, WidgetType, type DecorationSet } from '@codemirror/view';
import { Annotation, EditorState, SelectionRange, StateField, type Range } from '@codemirror/state'; import { Annotation, Compartment, EditorState, Prec, SelectionRange, StateField, type Extension, type Range } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { defaultKeymap, history, historyKeymap, standardKeymap } from '@codemirror/commands';
import { bracketMatching, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language'; import { bracketMatching, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
import { search, searchKeymap } from '@codemirror/search'; import { search, searchKeymap } from '@codemirror/search';
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'; import { acceptCompletion, autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { IterMode, Tree, type SyntaxNodeRef } from '@lezer/common'; import { IterMode, Tree, type SyntaxNodeRef } from '@lezer/common';
import { tags } from '@lezer/highlight'; import { tags } from '@lezer/highlight';
import { dom, type RedrawableHTML } from '#shared/dom.util'; import { div, dom, icon, span, type RedrawableHTML } from '#shared/dom.util';
import { callout as calloutExtension } from '#shared/grammar/callout.extension'; import { callout as calloutExtension, calloutKeymap } from '#shared/grammar/callout.extension';
import { wikilink as wikilinkExtension, autocompletion as wikilinkAutocompletion } from '#shared/grammar/wikilink.extension'; import { wikilink as wikilinkExtension, autocompletion as wikilinkAutocompletion } from '#shared/grammar/wikilink.extension';
import renderMarkdown from '#shared/markdown.util'; import renderMarkdown from '#shared/markdown.util';
import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout } from "#shared/proses"; import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout } from "#shared/proses";
import { tagTag, tag as tagExtension } from './grammar/tag.extension'; import { tagTag, tag as tagExtension } from './grammar/tag.extension';
import { WeakerSet } from './general.util';
import { button, numberpicker } from './components.util';
import { contextmenu, followermenu } from './floating.util';
const External = Annotation.define<boolean>(); const External = Annotation.define<boolean>();
const Hidden = Decoration.mark({ class: 'hidden' }); const Hidden = Decoration.mark({ class: 'hidden' });
@ -28,10 +31,10 @@ const intersects = (a: {
}) => !(a.to < b.from || b.to < a.from); }) => !(a.to < b.from || b.to < a.from);
const highlight = HighlightStyle.define([ const highlight = HighlightStyle.define([
{ tag: tags.heading1, class: 'text-5xl pt-4 pb-2 after:hidden' }, { tag: tags.heading1, class: 'text-5xl after:hidden' },
{ tag: tags.heading2, class: 'text-4xl pt-4 pb-2 ps-1 leading-loose after:hidden' }, { tag: tags.heading2, class: 'text-4xl ps-1 leading-loose after:hidden' },
{ tag: tags.heading3, class: 'text-2xl font-bold pt-1 after:hidden' }, { tag: tags.heading3, class: 'text-2xl font-bold after:hidden' },
{ tag: tags.heading4, class: 'text-xl font-semibold pt-1 after:hidden variant-cap' }, { tag: tags.heading4, class: 'text-xl font-semibold after:hidden variant-cap' },
{ tag: tags.meta, class: 'text-light-60 dark:text-dark-60' }, { tag: tags.meta, class: 'text-light-60 dark:text-dark-60' },
{ tag: tags.link, class: 'text-accent-blue hover:underline' }, { tag: tags.link, class: 'text-accent-blue hover:underline' },
{ tag: tags.special(tags.link), class: 'text-accent-blue font-semibold' }, { tag: tags.special(tags.link), class: 'text-accent-blue font-semibold' },
@ -107,7 +110,7 @@ class CalloutWidget extends WidgetType
} }
toDOM(view: EditorView) toDOM(view: EditorView)
{ {
return dom('div', { class: 'flex cm-line', listeners: { click: e => view.dispatch({ selection: { anchor: this.from, head: this.to } }) } }, [prose('blockquote', callout, [ this.contentMD ], { title: this.title, type: this.type, fold: this.foldable, class: '!m-px ' }) as RedrawableHTML | undefined]); return dom('div', { class: 'flex cm-line', listeners: { click: e => view.state.readOnly || view.dispatch({ selection: { anchor: this.from, head: this.to } }) } }, [prose('blockquote', callout, [ this.contentMD ], { title: this.title, type: this.type, fold: this.foldable, class: '!m-px ' })]);
} }
override ignoreEvent(event: Event) override ignoreEvent(event: Event)
{ {
@ -123,11 +126,10 @@ class Decorator
'CodeMark', 'CodeMark',
'CodeInfo', 'CodeInfo',
'URL', 'URL',
'CalloutMark',
'WikilinkMeta', 'WikilinkMeta',
'WikilinkHref', 'WikilinkHref',
'TagMeta' 'TagMeta'
] ];
decorations: DecorationSet; decorations: DecorationSet;
constructor(view: EditorView) constructor(view: EditorView)
{ {
@ -155,7 +157,7 @@ class Decorator
tree.iterate({ tree.iterate({
from, to, mode: IterMode.IgnoreMounts, from, to, mode: IterMode.IgnoreMounts,
enter: node => { enter: node => {
if(node.node.parent && node.node.parent.name !== 'Document' && selection.some(e => intersects(e, node.node.parent!))) if(node.node.parent && node.node.parent.name !== 'Document' && !state.readOnly && selection.some(e => intersects(e, node.node.parent!)))
return true; return true;
else if(node.name === 'HeaderMark') else if(node.name === 'HeaderMark')
@ -190,7 +192,7 @@ function blockIterate(tree: Tree, state: EditorState): Range<Decoration>[]
return true; return true;
else if(node.name === 'CalloutBlock') else if(node.name === 'CalloutBlock')
return decorations.push(Decoration.replace({ widget: CalloutWidget.create(node, state), block: true, }).range(node.from, node.to)), false; return decorations.push(Decoration.replace({ widget: CalloutWidget.create(node, state), block: true }).range(node.from, node.to)), false;
return true; return true;
}, },
@ -198,7 +200,7 @@ function blockIterate(tree: Tree, state: EditorState): Range<Decoration>[]
return decorations; return decorations;
} }
const BlockDecorator = StateField.define<DecorationSet>({ export const BlockDecorator = StateField.define<DecorationSet>({
create(state) create(state)
{ {
return Decoration.set(blockIterate(syntaxTree(state), state), true); return Decoration.set(blockIterate(syntaxTree(state), state), true);
@ -210,26 +212,55 @@ const BlockDecorator = StateField.define<DecorationSet>({
return decorations.map(transaction.changes); return decorations.map(transaction.changes);
}, },
provide: f => EditorView.decorations.from(f), provide: f => EditorView.decorations.from(f),
}) });
const BlockUndecorator = StateField.define({ create: (state) => {}, update: (value, transaction) => {} });
const _readonlyTrue = EditorState.readOnly.of(true);
const _readonlyFalse = EditorState.readOnly.of(false);
const _editableTrue = EditorView.editable.of(true);
const _editableFalse = EditorView.editable.of(false);
export class MarkdownEditor export class MarkdownEditor
{ {
private static _singleton: MarkdownEditor; private static _singleton: MarkdownEditor;
private static _set: WeakerSet<MarkdownEditor> = new WeakerSet();
private view: EditorView; private view: EditorView;
private viewer: 'read' | 'live' | 'edit' = 'live'; private _dom: HTMLElement;
private _readonly = new Compartment();
private _editable = new Compartment();
private _editStyle = new Compartment();
private _blockExtension = new Compartment();
private _decoratorHidden?: Extension;
private _decoratorVisible?: Extension;
onChange?: (content: string) => void; onChange?: (content: string) => void;
static settings(this: HTMLElement)
{
const viewer = MarkdownEditor.viewer;
this.parentElement?.toggleAttribute('data-focused', true);
const follower = followermenu(this, [
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'edit'; follower.close(); } } }, [span('', 'Modif. source'), viewer === 'edit' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'live'; follower.close(); } } }, [span('', 'Modifi. live'), viewer === 'live' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'read'; follower.close(); } } }, [span('', 'Lecture seule'), viewer === 'read' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
], { class: 'text-light-100 dark:text-dark-100', offset: 0, placement: 'right-start', blur: () => this.parentElement?.toggleAttribute('data-focused', false) });
return follower;
}
constructor() constructor()
{ {
this._dom = div('flex h-full relative', [ div('absolute -top-1 -left-1 -translate-x-px -translate-y-px z-10 group', [ div('group-hover:hidden group-data-[focused]:hidden w-0 h-0 border-8 border-transparent border-l-light-40 dark:border-l-dark-40 border-t-light-40 dark:border-t-dark-40'), button([icon('radix-icons:gear')], MarkdownEditor.settings, 'p-1 hidden group-data-[focused]:block group-hover:block') ]), ]);
this._decoratorVisible = ViewPlugin.fromClass(Decorator, {
decorations: undefined,
}).of(undefined);
this.view = new EditorView({ this.view = new EditorView({
extensions: [ extensions: [
markdown({ markdown({
base: markdownLanguage, base: markdownLanguage,
extensions: [ calloutExtension, wikilinkExtension, tagExtension ] extensions: [ calloutExtension, wikilinkExtension, tagExtension ]
}), }),
BlockDecorator, this._blockExtension.of(BlockDecorator),
history(), history(),
search(),
dropCursor(), dropCursor(),
EditorState.allowMultipleSelections.of(true), EditorState.allowMultipleSelections.of(true),
indentOnInput(), indentOnInput(),
@ -239,16 +270,19 @@ export class MarkdownEditor
autocompletion({ autocompletion({
icons: false, icons: false,
defaultKeymap: true, defaultKeymap: true,
maxRenderedOptions: 10, maxRenderedOptions: 25,
activateOnTyping: true, activateOnTyping: true,
override: [ wikilinkAutocompletion ] override: [ wikilinkAutocompletion ],
}), }),
crosshairCursor(), crosshairCursor(),
EditorView.lineWrapping, EditorView.lineWrapping,
Prec.high(keymap.of(calloutKeymap)),
keymap.of([ keymap.of([
...completionKeymap, ...completionKeymap,
{ key: 'Tab', run: acceptCompletion },
...closeBracketsKeymap, ...closeBracketsKeymap,
...defaultKeymap, ...defaultKeymap,
...standardKeymap,
...searchKeymap, ...searchKeymap,
...historyKeymap, ...historyKeymap,
]), ]),
@ -256,18 +290,49 @@ export class MarkdownEditor
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External))) if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
this.onChange && this.onChange(viewUpdate.state.doc.toString()); this.onChange && this.onChange(viewUpdate.state.doc.toString());
}), }),
this._readonly.of(_readonlyFalse),
this._editable.of(_editableTrue),
EditorView.contentAttributes.of({spellcheck: "true"}), EditorView.contentAttributes.of({spellcheck: "true"}),
ViewPlugin.fromClass(Decorator, { this._editStyle.of(this._decoratorVisible),
decorations: e => e.decorations, ],
}) parent: this._dom,
]
}); });
this.viewer = MarkdownEditor.viewer;
MarkdownEditor._set.add(this);
} }
focus() focus()
{ {
this.view.focus(); this.view.focus();
} }
static set viewer(value: 'live' | 'read' | 'edit')
{
localStorage.setItem('editor-view', value);
MarkdownEditor._set.forEach(e => e.viewer = value);
}
static get viewer(): 'live' | 'read' | 'edit'
{
return (localStorage.getItem('editor-view') ?? 'live') as 'live' | 'read' | 'edit';
}
set viewer(value: 'live' | 'read' | 'edit')
{
switch(value)
{
case 'edit':
this._decoratorVisible ??= ViewPlugin.fromClass(Decorator, { decorations: undefined, }).of(undefined);
this.view.dispatch({ effects: [ this._readonly.reconfigure(_readonlyFalse), this._blockExtension.reconfigure(BlockUndecorator), this._editable.reconfigure(_editableTrue), this._editStyle.reconfigure(this._decoratorVisible!) ] });
return;
case 'live':
this._decoratorHidden ??= ViewPlugin.fromClass(Decorator, { decorations: e => e.decorations, }).of(undefined);
this.view.dispatch({ effects: [ this._readonly.reconfigure(_readonlyFalse), this._blockExtension.reconfigure(BlockDecorator), this._editable.reconfigure(_editableTrue), this._editStyle.reconfigure(this._decoratorHidden!) ] });
return;
case 'read':
this._decoratorHidden ??= ViewPlugin.fromClass(Decorator, { decorations: e => e.decorations, }).of(undefined);
this.view.dispatch({ effects: [ this._readonly.reconfigure(_readonlyTrue), this._blockExtension.reconfigure(BlockDecorator), this._editable.reconfigure(_editableFalse), this._editStyle.reconfigure(this._decoratorHidden!) ] });
return;
}
}
set content(value: string) set content(value: string)
{ {
if (value === undefined) if (value === undefined)
@ -289,7 +354,7 @@ export class MarkdownEditor
get dom() get dom()
{ {
return this.view.dom; return this._dom;
} }
static get singleton(): MarkdownEditor static get singleton(): MarkdownEditor

View File

@ -9,7 +9,7 @@ import characterConfig from "#shared/character-config.json";
import { getID } from "#shared/general.util"; import { getID } from "#shared/general.util";
import markdown, { markdownReference, renderMDAsText } from "#shared/markdown.util"; import markdown, { markdownReference, renderMDAsText } from "#shared/markdown.util";
import { Tree } from "#shared/tree"; import { Tree } from "#shared/tree";
import { getText } from "#shared/i18n"; import { getText, setText } from "#shared/i18n";
type Category = ItemConfig['category']; type Category = ItemConfig['category'];
type Rarity = ItemConfig['rarity']; type Rarity = ItemConfig['rarity'];
@ -20,13 +20,8 @@ export class HomebrewBuilder
private _container: RedrawableHTML; private _container: RedrawableHTML;
private _tabs: RedrawableHTML; private _tabs: RedrawableHTML;
private _config: CharacterConfig;
private _featureEditor: FeaturePanel;
constructor(container: RedrawableHTML) constructor(container: RedrawableHTML)
{ {
this._config = config as CharacterConfig;
this._featureEditor = new FeaturePanel();
this._container = container; this._container = container;
this._tabs = tabgroup([ this._tabs = tabgroup([
@ -53,7 +48,7 @@ export class HomebrewBuilder
options: LEVELS.map(e => { options: LEVELS.map(e => {
const feature: Feature = { const feature: Feature = {
id: getID(), id: getID(),
description: '', description: getID(),
effect: [], effect: [],
} }
config.features[feature.id] = feature; config.features[feature.id] = feature;
@ -67,14 +62,14 @@ export class HomebrewBuilder
let element = dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50"], listeners: { click: e => { let element = dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50"], listeners: { click: e => {
FeaturePanel.edit(config.features[feature]!).then(e => { FeaturePanel.edit(config.features[feature]!).then(e => {
config.features[feature] = e; config.features[feature] = e;
element.replaceChildren(markdown(config.features[feature]!.description, undefined, { tags: { a: preview } })); element.replaceChildren(markdown(getText(config.features[feature]!.description), undefined, { tags: { a: preview } }));
}).catch(e => {}); }).catch(e => {});
}, contextmenu: (e) => { }, contextmenu: (e) => {
e.preventDefault(); e.preventDefault();
const context = contextmenu(e.clientX, e.clientY, [ const context = contextmenu(e.clientX, e.clientY, [
dom('div', { class: 'px-2 py-1 border-bottom border-light-35 dark:border-dark-35 cursor-pointer hover:bg-light-40 dark:hover:bg-dark-40 text-light-100 dark:text-dark-100', listeners: { click: () => { dom('div', { class: 'px-2 py-1 border-bottom border-light-35 dark:border-dark-35 cursor-pointer hover:bg-light-40 dark:hover:bg-dark-40 text-light-100 dark:text-dark-100', listeners: { click: () => {
context.close(); context.close();
const _feature: Feature = { id: getID(), description: '', effect: [] }; const _feature: Feature = { id: getID(), description: getID(), effect: [] };
config.features[_feature.id] = _feature; config.features[_feature.id] = _feature;
config.peoples[people]!.options[level]!.push(_feature.id); config.peoples[people]!.options[level]!.push(_feature.id);
element.parentElement?.appendChild(render(people, level, _feature.id)); element.parentElement?.appendChild(render(people, level, _feature.id));
@ -88,7 +83,7 @@ export class HomebrewBuilder
} }
}) } } }, [ text('Supprimer') ]) : undefined, }) } } }, [ text('Supprimer') ]) : undefined,
], { placement: "right-start", priority: false }); ], { placement: "right-start", priority: false });
}}}, [ markdown(config.features[feature]!.description, undefined, { tags: { a: preview } }) ]); }}}, [ markdown(getText(config.features[feature]!.description), undefined, { tags: { a: preview } }) ]);
return element; return element;
} }
const peopleRender = (people: RaceConfig) => { const peopleRender = (people: RaceConfig) => {
@ -116,14 +111,14 @@ export class HomebrewBuilder
let element = dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50"], listeners: { click: e => { let element = dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50"], listeners: { click: e => {
FeaturePanel.edit(config.features[feature]!).then(e => { FeaturePanel.edit(config.features[feature]!).then(e => {
config.features[feature] = e; config.features[feature] = e;
element.replaceChildren(markdown(config.features[feature]!.description, undefined, { tags: { a: preview } })); element.replaceChildren(markdown(getText(config.features[feature]!.description), undefined, { tags: { a: preview } }));
}).catch(e => {}); }).catch(e => {});
}, contextmenu: (e) => { }, contextmenu: (e) => {
e.preventDefault(); e.preventDefault();
const context = contextmenu(e.clientX, e.clientY, [ const context = contextmenu(e.clientX, e.clientY, [
dom('div', { class: 'px-2 py-1 border-bottom border-light-35 dark:border-dark-35 cursor-pointer hover:bg-light-40 dark:hover:bg-dark-40 text-light-100 dark:text-dark-100', listeners: { click: () => { dom('div', { class: 'px-2 py-1 border-bottom border-light-35 dark:border-dark-35 cursor-pointer hover:bg-light-40 dark:hover:bg-dark-40 text-light-100 dark:text-dark-100', listeners: { click: () => {
context.close(); context.close();
const _feature: Feature = { id: getID(), description: '', effect: [] }; const _feature: Feature = { id: getID(), description: getID(), effect: [] };
config.features[_feature.id] = _feature; config.features[_feature.id] = _feature;
config.training[stat][level].push(_feature.id); config.training[stat][level].push(_feature.id);
element.parentElement?.appendChild(render(stat, level, _feature.id)); element.parentElement?.appendChild(render(stat, level, _feature.id));
@ -137,7 +132,7 @@ export class HomebrewBuilder
} }
}) } } }, [ text('Supprimer') ]) : undefined, }) } } }, [ text('Supprimer') ]) : undefined,
], { placement: "right-start", priority: false }); ], { placement: "right-start", priority: false });
}}}, [ markdown(config.features[feature]!.description, undefined, { tags: { a: preview } }) ]); }}}, [ markdown(getText(config.features[feature]!.description), undefined, { tags: { a: preview } }) ]);
return element; return element;
}; };
const statRenderBlock = (stat: MainStat) => { const statRenderBlock = (stat: MainStat) => {
@ -180,7 +175,7 @@ export class HomebrewBuilder
} }
const add = () => { const add = () => {
const id = getID(); const id = getID();
this._config.aspects[id] = { config.aspects[id] = {
id, id,
name: '', name: '',
description: '', description: '',
@ -210,16 +205,16 @@ export class HomebrewBuilder
} }
}) })
} }
const redraw = () => table(Object.values(this._config.aspects).map(render), { name: 'Nom', description: 'Description', stat: 'Buff de stat', alignment: 'Alignement', magic: 'Magie', difficulty: 'Difficulté', physic: 'Physique', mental: 'Mental', personality: 'Caractère', action: 'Actions' }, { class: { table: 'flex-1' } }); const redraw = () => table(Object.values(config.aspects).map(render), { name: 'Nom', description: 'Description', stat: 'Buff de stat', alignment: 'Alignement', magic: 'Magie', difficulty: 'Difficulté', physic: 'Physique', mental: 'Mental', personality: 'Caractère', action: 'Actions' }, { class: { table: 'flex-1' } });
let content = redraw(); let content = redraw();
return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), add, 'p-1') ]), content ] ) ]; return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), add, 'p-1') ]), content ] ) ];
} }
spells() spells()
{ {
const render = (spell: SpellConfig) => { const render = (spell: SpellConfig) => {
return foldable([ return spell.type === 'arts' ? undefined : foldable([
dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Rang'), select([{ text: 'Rang 1', value: 1 }, { text: 'Rang 2', value: 2 }, { text: 'Rang 3', value: 3 }, { text: 'Spécial', value: 4 }], { change: (value: 1 | 2 | 3 | 4) => spell.rank = value, defaultValue: spell.rank, class: { container: '!m-0 !h-9 w-full' } }), ]), dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Rang'), select([{ text: 'Rang 1', value: 1 }, { text: 'Rang 2', value: 2 }, { text: 'Rang 3', value: 3 }, { text: 'Spécial', value: 4 }], { change: (value: 1 | 2 | 3 | 4) => spell.rank = value, defaultValue: spell.rank, class: { container: '!m-0 !h-9 w-full' } }), ]),
dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Type'), select(SPELL_TYPES.map(f => ({ text: spellTypeTexts[f], value: f })), { change: (value) => spell.type = value, defaultValue: spell.type, class: { container: '!m-0 !h-9 w-full' } }), ]), dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Type'), select(SPELL_TYPES.filter(e => e !== 'arts').map(f => ({ text: spellTypeTexts[f], value: f })), { change: (value) => spell.type = value, defaultValue: spell.type, class: { container: '!m-0 !h-9 w-full' } }), ]),
dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Coût'), numberpicker({ defaultValue: spell.cost, input: (value) => spell.cost = value, class: '!m-0 w-full' }), ]), dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Coût'), numberpicker({ defaultValue: spell.cost, input: (value) => spell.cost = value, class: '!m-0 w-full' }), ]),
dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Incantation'), select<'action' | 'reaction' | number>([{ text: 'Action', value: 'action' }, { text: 'Reaction', value: 'reaction' }, { text: '1 minute', value: 1 }, { text: '10 minutes', value: 10 }], { change: (value) => spell.speed = value, defaultValue: spell.speed, class: { container: '!m-0 !h-9 w-full' } }), ]), dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Incantation'), select<'action' | 'reaction' | number>([{ text: 'Action', value: 'action' }, { text: 'Reaction', value: 'reaction' }, { text: '1 minute', value: 1 }, { text: '10 minutes', value: 10 }], { change: (value) => spell.speed = value, defaultValue: spell.speed, class: { container: '!m-0 !h-9 w-full' } }), ]),
dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Elements'), multiselect(SPELL_ELEMENTS.map(f => ({ text: elementTexts[f].text, value: f })), { change: (value) => spell.elements = value, defaultValue: spell.elements, class: { container: '!m-0 !h-9 w-full' } }), ]), dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Elements'), multiselect(SPELL_ELEMENTS.map(f => ({ text: elementTexts[f].text, value: f })), { change: (value) => spell.elements = value, defaultValue: spell.elements, class: { container: '!m-0 !h-9 w-full' } }), ]),
@ -230,7 +225,7 @@ export class HomebrewBuilder
} }
const add = () => { const add = () => {
const id = getID(); const id = getID();
this._config.spells[id] = { config.spells[id] = {
id, id,
name: '', name: '',
rank: 1, rank: 1,
@ -252,7 +247,7 @@ export class HomebrewBuilder
confirm('Voulez vous vraiment supprimer ce sort ?').then(e => { confirm('Voulez vous vraiment supprimer ce sort ?').then(e => {
if(e) if(e)
{ {
delete this._config.spells[spell.id]; delete config.spells[spell.id];
const element = redraw(); const element = redraw();
content.parentElement?.replaceChild(element, content); content.parentElement?.replaceChild(element, content);
@ -260,7 +255,7 @@ export class HomebrewBuilder
} }
}); });
} }
const redraw = () => div('flex flex-col divide-y', Object.values(this._config.spells).map(render)); const redraw = () => div('flex flex-col divide-y', Object.values(config.spells).map(render));
let content = redraw(); let content = redraw();
return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), add, 'p-1') ]), content ] ) ]; return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), add, 'p-1') ]), content ] ) ];
} }
@ -288,20 +283,20 @@ export class HomebrewBuilder
description: getID(), // i18nID description: getID(), // i18nID
cost: type === 'action' || type === 'reaction' ? 1 : undefined, cost: type === 'action' || type === 'reaction' ? 1 : undefined,
} }
this._config.texts[feature.description] = { 'fr_FR': '', default: '' }; config.texts[feature.description] = { 'fr_FR': '' };
this._config[type][feature.id] = feature; config[type][feature.id] = feature;
const option = render(type, feature); const option = render(type, feature);
options.push(option); options.push(option);
optionHolder.appendChild(option.dom); optionHolder.appendChild(option.dom);
}; };
const remove = (type: 'action' | 'reaction' | 'freeaction' | 'passive', id: string) => { const remove = (type: 'action' | 'reaction' | 'freeaction' | 'passive', id: string) => {
const feature = this._config[type][id]!; const feature = config[type][id]!;
confirm(`Voulez vous vraiment supprimer l'effet "${feature.name}" ?`).then(e => { confirm(`Voulez vous vraiment supprimer l'effet "${feature.name}" ?`).then(e => {
if(e) if(e)
{ {
delete this._config.texts[feature.description]; delete config.texts[feature.description];
delete this._config[type][id]; delete config[type][id];
const idx = options.findIndex(e => e.type === type && e.id === id); const idx = options.findIndex(e => e.type === type && e.id === id);
options.splice(idx, 1)[0]?.dom.remove(); options.splice(idx, 1)[0]?.dom.remove();
@ -309,13 +304,13 @@ export class HomebrewBuilder
}); });
}; };
const edit = (type: 'action' | 'reaction' | 'freeaction' | 'passive', id: string) => { const edit = (type: 'action' | 'reaction' | 'freeaction' | 'passive', id: string) => {
const feature = this._config[type][id]!; const feature = config[type][id]!;
const option = options.find(e => e.type === type && e.id === id); const option = options.find(e => e.type === type && e.id === id);
if(editing) if(editing)
{ {
const idx = options.findIndex(e => e.id === editing!.id && e.type === editing!.type); const idx = options.findIndex(e => e.id === editing!.id && e.type === editing!.type);
const rerender = render(editing.type, this._config[editing.type][editing.id]!); const rerender = render(editing.type, config[editing.type][editing.id]!);
options[idx]?.dom.replaceWith(rerender.dom); options[idx]?.dom.replaceWith(rerender.dom);
options[idx] = rerender; options[idx] = rerender;
@ -323,8 +318,7 @@ export class HomebrewBuilder
editing = { id, type }; editing = { id, type };
const buttons = div('flex flex-row items-center gap-2', [ span('text-sm text-light-70 dark:text-dark-70', type), tooltip(button(icon('radix-icons:check'), () => { const buttons = div('flex flex-row items-center gap-2', [ span('text-sm text-light-70 dark:text-dark-70', type), tooltip(button(icon('radix-icons:check'), () => {
this._config.texts[feature.description]!.default = editor.content; setText(feature.description, editor.content);
this._config.texts[feature.description]!['fr_FR'] = editor.content;
const rerender = render(type, feature); const rerender = render(type, feature);
option!.buttons.replaceWith(rerender.buttons); option!.buttons.replaceWith(rerender.buttons);
@ -357,7 +351,7 @@ export class HomebrewBuilder
option!.md.current.replaceWith(editorDom); option!.md.current.replaceWith(editorDom);
option!.md.current = editorDom; option!.md.current = editorDom;
} }
const options = [...Object.values(this._config.action).map(e => render('action', e)), ...Object.values(this._config.reaction).map(e => render('reaction', e)), ...Object.values(this._config.freeaction).map(e => render('freeaction', e)), ...Object.values(this._config.passive).map(e => render('passive', e))]; const options = [...Object.values(config.action).map(e => render('action', e)), ...Object.values(config.reaction).map(e => render('reaction', e)), ...Object.values(config.freeaction).map(e => render('freeaction', e)), ...Object.values(config.passive).map(e => render('passive', e))];
const optionHolder = div('flex flex-col gap-4', options.map(e => e.dom)); const optionHolder = div('flex flex-col gap-4', options.map(e => e.dom));
return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), optionmenu([{ title: 'Action', click: () => add('action') }, { title: 'Réaction', click: () => add('reaction') }, { title: 'Action libre', click: () => add('freeaction') }, { title: 'Passif', click: () => add('passive') }], { position: 'left-start' }), 'p-1') ]), optionHolder ] ) ]; return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), optionmenu([{ title: 'Action', click: () => add('action') }, { title: 'Réaction', click: () => add('reaction') }, { title: 'Action libre', click: () => add('freeaction') }, { title: 'Passif', click: () => add('passive') }], { position: 'left-start' }), 'p-1') ]), optionHolder ] ) ];
} }
@ -396,8 +390,8 @@ export class HomebrewBuilder
}; };
const add = (category: Category) => { const add = (category: Category) => {
const item = defaultItem(category); const item = defaultItem(category);
this._config.texts[item.description!] = { 'fr_FR': '', default: '' }; config.texts[item.description!] = { 'fr_FR': '' };
this._config.items[item.id!] = item; config.items[item.id!] = item;
const option = render(item); const option = render(item);
options.push(option); options.push(option);
@ -407,8 +401,8 @@ export class HomebrewBuilder
confirm(`Voulez vous vraiment supprimer l'effet "${item.name}" ?`).then(e => { confirm(`Voulez vous vraiment supprimer l'effet "${item.name}" ?`).then(e => {
if(e) if(e)
{ {
delete this._config.texts[item.description]; delete config.texts[item.description];
delete this._config.items[item.id]; delete config.items[item.id];
const idx = options.findIndex(e => e.item === item); const idx = options.findIndex(e => e.item === item);
options.splice(idx, 1)[0]?.dom.remove(); options.splice(idx, 1)[0]?.dom.remove();
@ -418,19 +412,19 @@ export class HomebrewBuilder
const edit = (item: ItemConfig) => { const edit = (item: ItemConfig) => {
ItemPanel.edit(item).then(f => { ItemPanel.edit(item).then(f => {
const idx = options.findIndex(e => e.item === item); const idx = options.findIndex(e => e.item === item);
this._config.items[item.id] = f; config.items[item.id] = f;
const element = render(f); const element = render(f);
options[idx]?.dom.replaceWith(element.dom); options[idx]?.dom.replaceWith(element.dom);
options[idx] = element; options[idx] = element;
}).catch((e) => {}); }).catch((e) => {});
} }
const options = Object.values(this._config.items).map(e => render(e)); const options = Object.values(config.items).map(e => render(e));
const optionHolder = div('grid grid-cols-3 gap-2', options.map(e => e.dom)); const optionHolder = div('grid grid-cols-3 gap-2', options.map(e => e.dom));
return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), optionmenu([{ title: 'Objet inerte', click: () => add('mundane') }, { title: 'Armure', click: () => add('armor') }, { title: 'Arme', click: () => add('weapon') }, { title: 'Objet magique', click: () => add('wondrous') }], { position: 'left-start' }), 'p-1') ]), optionHolder ] ) ]; return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), optionmenu([{ title: 'Objet inerte', click: () => add('mundane') }, { title: 'Armure', click: () => add('armor') }, { title: 'Arme', click: () => add('weapon') }, { title: 'Objet magique', click: () => add('wondrous') }], { position: 'left-start' }), 'p-1') ]), optionHolder ] ) ];
} }
private save() private save()
{ {
navigator.clipboard.writeText(JSON.stringify(this._config)); navigator.clipboard.writeText(JSON.stringify(config));
} }
} }
@ -529,7 +523,7 @@ class FeatureEditor
private editByCategory(buffer: FeatureOption) private editByCategory(buffer: FeatureOption)
{ {
let top: NodeChildren = [], bottom: NodeChildren = []; let top: NodeChildren = [], bottom: NodeChildren = [];
switch(this.option.category) switch(buffer.category)
{ {
case 'value': case 'value':
return this.editValue(buffer as Partial<FeatureValue>); return this.editValue(buffer as Partial<FeatureValue>);
@ -622,8 +616,8 @@ class FeatureEditor
return element; return element;
} }
const renderOption = (option: { text: string; effects: (Partial<FeatureValue | FeatureList>)[] }, state: boolean) => { const renderOption = (option: { text: string; effects: (Partial<FeatureValue | FeatureList>)[] }, state: boolean) => {
const effects = div('flex flex-col -m-px flex flex-col ms-px ps-8 w-full', option.effects.map(e => renderEffect(option, e))); const effects = div('flex flex-col -m-px flex flex-col ms-px ps-8 w-full', option.effects.map(e => renderEffect(option, e))), effectLength = text(option.effects.length);
let _content = foldable([ effects ], [ div('flex flex-row flex-1 justify-between', [ input('text', { defaultValue: option.text, input: (value) => { option.text = value }, placeholder: 'Nom de l\'option', class: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] flex-shrink-1' }), div('flex flex-row flex-shrink-1', [ tooltip(button(icon('radix-icons:plus'), () => effects.appendChild(renderEffect(option, addEffect(option))), 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Nouvel effet', 'bottom'), , tooltip(button(icon('radix-icons:trash'), () => { let _content = foldable([ effects ], [ div('flex flex-row flex-1 justify-between', [ div('flex flex-row items-center', [ input('text', { defaultValue: option.text, input: (value) => { option.text = value }, placeholder: 'Nom de l\'option', class: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] flex-shrink-1' }), span('italic ps-8 pe-2', 'Effets: '), span('font-bold', effectLength) ]), div('flex flex-row flex-shrink-1', [ tooltip(button(icon('radix-icons:plus'), () => effects.appendChild(renderEffect(option, addEffect(option))), 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Nouvel effet', 'bottom'), , tooltip(button(icon('radix-icons:trash'), () => {
_content.remove(); _content.remove();
buffer.options?.splice(buffer.options.findIndex(e => e !== option), 1); buffer.options?.splice(buffer.options.findIndex(e => e !== option), 1);
}, 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Supprimer', 'bottom') ]) ]) ], { class: { title: 'border-b border-light-35 dark:border-dark-35', icon: 'w-[34px] h-[34px]', content: 'border-b border-light-35 dark:border-dark-35' }, open: state }); }, 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Supprimer', 'bottom') ]) ]) ], { class: { title: 'border-b border-light-35 dark:border-dark-35', icon: 'w-[34px] h-[34px]', content: 'border-b border-light-35 dark:border-dark-35' }, open: state });
@ -668,7 +662,7 @@ export class FeaturePanel
const _feature = JSON.parse(JSON.stringify(feature)) as Feature; const _feature = JSON.parse(JSON.stringify(feature)) as Feature;
const effectContainer = div('grid grid-cols-2 gap-4 px-2', _feature.effect.map(e => new FeatureEditor(_feature.effect!, e.id, false).container)); const effectContainer = div('grid grid-cols-2 gap-4 px-2', _feature.effect.map(e => new FeatureEditor(_feature.effect!, e.id, false).container));
MarkdownEditor.singleton.content = getText(_feature.description); MarkdownEditor.singleton.content = getText(_feature.description);
MarkdownEditor.singleton.onChange = (value) => config.texts[_feature.description]!.default = value; MarkdownEditor.singleton.onChange = (value) => setText(_feature.description, value);
return dom('div', { attributes: { 'data-state': 'inactive' }, class: 'border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-2 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]' }, [ return dom('div', { attributes: { 'data-state': 'inactive' }, class: 'border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-2 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]' }, [
div('flex flex-row justify-between items-center', [ div('flex flex-row justify-between items-center', [
tooltip(button(icon('radix-icons:check', { width: 20, height: 20 }), () => { tooltip(button(icon('radix-icons:check', { width: 20, height: 20 }), () => {
@ -689,7 +683,7 @@ export class FeaturePanel
dom('span', { class: 'pb-1 md:p-0', text: "Description" }), dom('span', { class: 'pb-1 md:p-0', text: "Description" }),
tooltip(button(icon('radix-icons:clipboard', { width: 20, height: 20 }), () => { tooltip(button(icon('radix-icons:clipboard', { width: 20, height: 20 }), () => {
MarkdownEditor.singleton.content = _feature?.effect.map(e => textFromEffect(e)).join('\n') ?? _feature?.description ?? MarkdownEditor.singleton.content; MarkdownEditor.singleton.content = _feature?.effect.map(e => textFromEffect(e)).join('\n') ?? _feature?.description ?? MarkdownEditor.singleton.content;
if(_feature?.description) _feature.description = MarkdownEditor.singleton.content; setText(_feature.description, MarkdownEditor.singleton.content);
}, 'p-1'), 'Description automatique', 'left'), }, 'p-1'), 'Description automatique', 'left'),
]), ]),
div('p-1 border border-light-40 dark:border-dark-40 w-full bg-light-25 dark:bg-dark-25 min-h-48 max-h-[32rem]', [ MarkdownEditor.singleton.dom ]), div('p-1 border border-light-40 dark:border-dark-40 w-full bg-light-25 dark:bg-dark-25 min-h-48 max-h-[32rem]', [ MarkdownEditor.singleton.dom ]),
@ -731,9 +725,9 @@ export class ItemPanel
{ {
const _item = JSON.parse(JSON.stringify(item)) as ItemConfig; const _item = JSON.parse(JSON.stringify(item)) as ItemConfig;
ItemPanel.descriptionEditor.content = getText(_item.description); ItemPanel.descriptionEditor.content = getText(_item.description);
ItemPanel.descriptionEditor.onChange = (value) => config.texts[_item.description]!.default = value; ItemPanel.descriptionEditor.onChange = (value) => setText(_item.description, value);
ItemPanel.flavoringEditor.content = getText(_item.flavoring); ItemPanel.flavoringEditor.content = getText(_item.flavoring);
ItemPanel.flavoringEditor.onChange = (value) => config.texts[_item.flavoring]!.default = value; ItemPanel.flavoringEditor.onChange = (value) => { if(!_item.flavoring) { _item.flavoring = getID(); } setText(_item.flavoring, value); };
const effectContainer = div('grid grid-cols-2 gap-4 px-2 flex-1', _item.effects?.map(e => new FeatureEditor(_item.effects!, e.id, false).container)); const effectContainer = div('grid grid-cols-2 gap-4 px-2 flex-1', _item.effects?.map(e => new FeatureEditor(_item.effects!, e.id, false).container));
return dom('div', { attributes: { 'data-state': 'inactive' }, class: 'border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-2 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]' }, [ return dom('div', { attributes: { 'data-state': 'inactive' }, class: 'border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-2 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]' }, [
div('flex flex-row justify-between items-center', [ div('flex flex-row justify-between items-center', [
@ -808,11 +802,13 @@ const featureChoices: Option<Partial<FeatureOption>>[] = [
{ text: 'Initiative', value: { category: 'value', property: 'initiative', operation: 'add', value: 1 }, }, { text: 'Initiative', value: { category: 'value', property: 'initiative', operation: 'add', value: 1 }, },
{ text: 'Points d\'entrainement', value: { category: 'value', property: 'training', operation: 'add', value: 1 }, }, { text: 'Points d\'entrainement', value: { category: 'value', property: 'training', operation: 'add', value: 1 }, },
{ text: 'Points de compétence', value: { category: 'value', property: 'ability', operation: 'add', value: 1 }, }, { text: 'Points de compétence', value: { category: 'value', property: 'ability', operation: 'add', value: 1 }, },
{ text: 'Sort bonus', value: { category: 'list', list: 'spells', action: 'add' }, }, { text: 'Sort inné', value: { category: 'list', list: 'spells', action: 'add' }, },
{ text: 'Point d\'action', value: { category: 'value', property: 'action', operation: 'set', value: 1 }, },
{ text: 'Point de réaction', value: { category: 'value', property: 'reaction', operation: 'set', value: 1 }, },
{ text: 'Puissance magique', value: { category: 'value', property: 'itempower', operation: 'add', value: 1 }, },
{ text: 'Spécialisation', value: { category: 'value', property: 'spec', operation: 'add', value: 1 }, }, { text: 'Spécialisation', value: { category: 'value', property: 'spec', operation: 'add', value: 1 }, },
{ text: 'Objets', value: [
{ text: 'Puissance magique', value: { category: 'value', property: 'itempower', operation: 'add', value: 1 }, },
{ text: 'Rareté fabricable', value: { category: 'value', property: 'craft/level', operation: 'add', value: 1 }, },
{ text: 'Bonus de fabrication', value: { category: 'value', property: 'craft/bonus', operation: 'add', value: 1 }, },
] },
{ text: 'Défense', value: [ { text: 'Défense', value: [
{ text: 'Défense max', value: { category: 'value', property: 'defense/hardcap', operation: 'add', value: 1 } }, { text: 'Défense max', value: { category: 'value', property: 'defense/hardcap', operation: 'add', value: 1 } },
{ text: 'Défense fixe', value: { category: 'value', property: 'defense/static', operation: 'add', value: 1 } }, { text: 'Défense fixe', value: { category: 'value', property: 'defense/static', operation: 'add', value: 1 } },
@ -872,12 +868,33 @@ const featureChoices: Option<Partial<FeatureOption>>[] = [
{ text: 'Résistance > Psyché', effects: [{ category: 'value', property: 'bonus/defense/psyche', operation: 'add', value: 1 }] } { text: 'Résistance > Psyché', effects: [{ category: 'value', property: 'bonus/defense/psyche', operation: 'add', value: 1 }] }
]} as Partial<FeatureChoice>} ]} as Partial<FeatureChoice>}
] }, ] },
{ text: 'Bonus', value: RESISTANCES.map(e => ({ text: resistanceTexts[e as Resistance], value: { category: 'value', property: `resistance/${e}`, operation: 'add', value: 1 } })) }, { text: 'Bonus à l\'attaque', value: RESISTANCES.map(e => ({ text: `Bonus > ${resistanceTexts[e]}`, value: { category: 'value', property: `resistance/${e}`, operation: 'add', value: 1 } })) },
{ text: 'Rang', value: [ { text: 'Magie', value: [
{ text: 'Sorts de précision', value: { category: 'value', property: 'spellranks/precision', operation: 'add', value: 1 } }, { text: 'Rang', value: [
{ text: 'Sorts de savoir', value: { category: 'value', property: 'spellranks/knowledge', operation: 'add', value: 1 } }, { text: 'Rang > Sorts de précision', value: { category: 'value', property: 'spellranks/precision', operation: 'add', value: 1 } },
{ text: 'Sorts d\'instinct', value: { category: 'value', property: 'spellranks/instinct', operation: 'add', value: 1 } }, { text: 'Rang > Sorts de savoir', value: { category: 'value', property: 'spellranks/knowledge', operation: 'add', value: 1 } },
{ text: 'Œuvres', value: { category: 'value', property: 'spellranks/arts', operation: 'add', value: 1 } }, { text: 'Rang > Sorts d\'instinct', value: { category: 'value', property: 'spellranks/instinct', operation: 'add', value: 1 } },
{ text: 'Rang > Œuvres', value: { category: 'value', property: 'spellranks/arts', operation: 'add', value: 1 } },
] },
{ text: 'Bonus par type', value: [
{ text: 'Bonus > Précision', value: { category: 'value', property: 'bonus/spells/type/precision', operation: 'add', value: 1 } },
{ text: 'Bonus > Savoir', value: { category: 'value', property: 'bonus/spells/type/knowledge', operation: 'add', value: 1 } },
{ text: 'Bonus > Instinct', value: { category: 'value', property: 'bonus/spells/type/instinct', operation: 'add', value: 1 } },
{ text: 'Bonus > Œuvres', value: { category: 'value', property: 'bonus/spells/type/arts', operation: 'add', value: 1 } },
] },
{ text: 'Bonus par rang', value: [
{ text: 'Bonus > Sorts de rang 1', value: { category: 'value', property: 'bonus/spells/rank/1', operation: 'add', value: 1 } },
{ text: 'Bonus > Sorts de rang 2', value: { category: 'value', property: 'bonus/spells/rank/2', operation: 'add', value: 1 } },
{ text: 'Bonus > Sorts de rang 3', value: { category: 'value', property: 'bonus/spells/rank/3', operation: 'add', value: 1 } },
{ text: 'Bonus > Sorts uniques', value: { category: 'value', property: 'bonus/spells/rank/4', operation: 'add', value: 1 } },
] },
{ text: 'Bonus par element', value: SPELL_ELEMENTS.map(e => ({ text: `Bonus > ${elementTexts[e].text}`, value: { category: 'value', property: `bonus/spells/elements/${e}`, operation: 'add', value: 1 } })) },
] },
{ text: 'Aspect', value: [
{ text: 'Aspect > Durée', value: { category: 'value', property: 'aspect/duration', operation: 'add', value: 15 } },
{ text: 'Aspect > Nombre', value: { category: 'value', property: 'aspect/amount', operation: 'add', value: 1 } },
{ text: 'Aspect > Bonus au jet', value: { category: 'value', property: 'aspect/bonus', operation: 'add', value: 1 } },
{ text: 'Aspect > Tier', value: { category: 'value', property: 'aspect/tier', operation: 'add', value: 1 } },
] }, ] },
{ text: 'Fatigue supportable', value: { category: 'value', property: 'exhaust', operation: 'add', value: 1 } }, { text: 'Fatigue supportable', value: { category: 'value', property: 'exhaust', operation: 'add', value: 1 } },
{ text: 'Action', value: { category: 'list', list: 'action', action: 'add' }, }, { text: 'Action', value: { category: 'list', list: 'action', action: 'add' }, },
@ -918,10 +935,6 @@ function textFromEffect(effect: Partial<FeatureOption>): string
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' spécialisation(s).' } }) : `Opération interdite (Spécialisation fixe).`; return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' spécialisation(s).' } }) : `Opération interdite (Spécialisation fixe).`;
case 'itempower': case 'itempower':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' puissance magique supportable.' } }) : `Opération interdite (Puissance magique fixe).`; return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' puissance magique supportable.' } }) : `Opération interdite (Puissance magique fixe).`;
case 'action':
return effect.operation === 'add' ? `Opération interdite (Point d'action bonus).` : textFromValue(effect.value, { suffix: { truely: ' point(s) d\'action par tour.' }, falsely: 'Opération interdite (Action = interdit).' });
case 'reaction':
return effect.operation === 'add' ? `Opération interdite (Point de réaction bonus).` : textFromValue(effect.value, { suffix: { truely: ' point(s) de réaction par tour.' }, falsely: 'Opération interdite (Réaction = interdit).' });
case 'exhaust': case 'exhaust':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Vous êtes capable de supporter ', positive: '+', text: '+Mod. de ' }, suffix: { truely: ' point(s) de fatigue avant de subir les effets de la fatigue.' } }) : `Opération interdite (Fatigue fixe).`; return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Vous êtes capable de supporter ', positive: '+', text: '+Mod. de ' }, suffix: { truely: ' point(s) de fatigue avant de subir les effets de la fatigue.' } }) : `Opération interdite (Fatigue fixe).`;
default: break; default: break;
@ -934,13 +947,26 @@ function textFromEffect(effect: Partial<FeatureOption>): string
switch(splited[1]) switch(splited[1])
{ {
case 'precision': case 'precision':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' rang de sort de précision.' } }) : `Opération interdite (Rang de sorts de précision fixe).`; return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' rang(s) de sort de précision.' } }) : `Opération interdite (Rang de sorts de précision fixe).`;
case 'knowledge': case 'knowledge':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' rang de sort de savoir.' } }) : `Opération interdite (Rang de sorts de savoir fixe).`; return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' rang(s) de sort de savoir.' } }) : `Opération interdite (Rang de sorts de savoir fixe).`;
case 'instinct': case 'instinct':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' rang de sort d\'instinct.' } }) : `Opération interdite (Rang de sorts d\'instinct fixe).`; return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' rang(s) de sort d\'instinct.' } }) : `Opération interdite (Rang de sorts d\'instinct fixe).`;
case 'arts': case 'arts':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' rang d\'œuvres.' } }) : `Opération interdite (Rang d\'œuvres fixe).`; return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' rang(s) d\'œuvres.' } }) : `Opération interdite (Rang d\'œuvres fixe).`;
default: return 'Type de sort inconnu.';
}
case 'aspect':
switch(splited[1])
{
case 'bonus':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' au jet de transformation.' } }) : `Opération interdite (Bonus de transformation fixe).`;
case 'duration':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' minute(s) de temps de transformation.' } }) : `Opération interdite (Durée de transformation fixe).`;
case 'amount':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' transformation(s) par jour.' } }) : `Opération interdite (Nombre de transformation fixe).`;
case 'tier':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' tier(s) de transformation.' } }) : `Opération interdite (Rang d\'œuvres fixe).`;
default: return 'Type de sort inconnu.'; default: return 'Type de sort inconnu.';
} }
case 'defense': case 'defense':

View File

@ -180,3 +180,37 @@ export function decryptURI(uri: string, key: string): number | undefined
else else
return undefined; return undefined;
} }
export class WeakerSet<T extends WeakKey>
{
private _arr: WeakRef<T>[] = [];
private _registry: FinalizationRegistry<any>;
constructor()
{
this._registry = new FinalizationRegistry<any>(this.clean);
}
add(item: T)
{
this._arr.push(new WeakRef(item));
this._registry.register(item, undefined);
}
clean()
{
for(let i = this._arr.length; i >= 0; i--)
{
if(this._arr[i]?.deref() === undefined)
this._arr.splice(i, 1);
}
}
forEach(cb: (value: T) => void)
{
for(let i = this._arr.length; i >= 0; i--)
{
const ref = this._arr[i]?.deref();
if(ref !== undefined)
cb(ref);
else
this._arr.splice(i, 1);
}
}
}

View File

@ -1,5 +1,8 @@
import type { MarkdownConfig } from '@lezer/markdown'; import type { MarkdownConfig } from '@lezer/markdown';
import { styleTags, tags } from '@lezer/highlight'; import { styleTags, tags } from '@lezer/highlight';
import type { Decoration, EditorView, KeyBinding } from '@codemirror/view';
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state';
import { BlockDecorator } from '../editor.util';
export const callout: MarkdownConfig = { export const callout: MarkdownConfig = {
defineNodes: [ defineNodes: [
@ -19,6 +22,7 @@ export const callout: MarkdownConfig = {
if (!match || !match[1]) return false; //No match if (!match || !match[1]) return false; //No match
const start = cx.lineStart, children = []; const start = cx.lineStart, children = [];
let continued = false;
const quoteEnd = start + line.text.indexOf('[!'); const quoteEnd = start + line.text.indexOf('[!');
const typeStart = quoteEnd + 2; const typeStart = quoteEnd + 2;
@ -29,16 +33,16 @@ export const callout: MarkdownConfig = {
if(match[2]) children.push(cx.elt('CalloutTitle', bracketEnd + 1, start + line.text.length)); if(match[2]) children.push(cx.elt('CalloutTitle', bracketEnd + 1, start + line.text.length));
while (cx.nextLine() && line.text.startsWith('>')) while ((continued = cx.nextLine()) && line.text.startsWith('>'))
{ {
const pos = line.text.substring(1).search(/\S/) + 1; const pos = line.text.substring(1).search(/\S/) + 1;
children.push(cx.elt('CalloutLine', cx.lineStart, cx.lineStart + line.text.length, [ children.push(cx.elt('CalloutLine', cx.lineStart, cx.lineStart + line.text.length, [
cx.elt('CalloutMark', cx.lineStart, cx.lineStart + pos), cx.elt('CalloutMark', cx.lineStart, cx.lineStart + pos),
cx.elt('CalloutContent', cx.lineStart + pos, cx.lineStart + line.text.length), cx.elt('CalloutContent', cx.lineStart + pos, cx.lineStart + line.text.length),
])) ]));
} }
cx.addElement(cx.elt('CalloutBlock', start, cx.lineStart - 1, children)); cx.addElement(cx.elt('Blockquote', start, continued ? cx.lineStart - 1 : cx.lineStart, [cx.elt('CalloutBlock', start, continued ? cx.lineStart - 1 : cx.lineStart, children)]));
return true; return true;
} }
@ -55,3 +59,69 @@ export const callout: MarkdownConfig = {
}) })
] ]
}; };
//Use the BlockDecorator to fetch every built block widgets and try to check if the future line should be positionned inside a block
function fetchBlockLine(state: EditorState, range: SelectionRange, forward: boolean): SelectionRange | null
{
const start = state.doc.lineAt(range.head), next = start.number + (forward ? 1 : -1);
if (next < 1 || next > state.doc.lines)
return null;
const nextLine = state.doc.line(next);
let matched = !0, current: Decoration | null = null;
state.field(BlockDecorator, false)?.between(nextLine.from, nextLine.to, (from, to, value) => {
if (value.spec.block)
{
if(current || from > nextLine.from || to < nextLine.to)
return (matched = false);
else
{
if(!current) current = value;
return;
}
}
});
if (!matched || !current)
return null;
if (!(current as Decoration).spec.block)
return null;
const position = range.head - start.from;
return EditorSelection.cursor(nextLine.from + Math.min(nextLine.length, position), range.assoc)
}
function moveCursor(view: EditorView, select: boolean, forward: boolean)
{
const state = view.state, selection = state.selection;
const range = EditorSelection.create(selection.ranges.map((range) => {
if(!select && !range.empty) //If I have already selected something and I stop holding Shift, I just deselect
return EditorSelection.cursor(forward ? range.to : range.from);
let target = view.moveVertically(range, forward);
const blockTarget = fetchBlockLine(state, range, forward);
target = blockTarget && Math.abs(target.head - range.head) > Math.abs(blockTarget.head - range.head) ? blockTarget : target.head != range.head ? target : view.moveToLineBoundary(range, forward);
return select ? EditorSelection.range(range.anchor, target.head, target.goalColumn) : target;
}, selection.mainIndex));
view.dispatch({
userEvent: 'select',
selection: range,
scrollIntoView: true,
});
return true;
}
export const calloutKeymap: KeyBinding[] = [
{
key: "ArrowUp",
run: (view) => moveCursor(view, false, false),
shift: (view) => moveCursor(view, true, false),
},
{
key: "ArrowDown",
run: (view) => moveCursor(view, false, true),
shift: (view) => moveCursor(view, true, true),
}
]

View File

@ -9,19 +9,20 @@ export const tag: MarkdownConfig = {
], ],
parseInline: [{ parseInline: [{
name: 'Tag', name: 'Tag',
after: 'Wikilink',
parse(cx, next, pos) parse(cx, next, pos)
{ {
//35 == '#' //35 == '#'
if (cx.slice(pos, pos + 1).charCodeAt(0) !== 35 || String.fromCharCode(next).trim() === '') return -1; if (cx.slice(pos, pos + 1).charCodeAt(0) !== 35 || String.fromCharCode(next).trim() === '') return -1;
if(pos !== 0 && cx.slice(pos - 1, pos).match(/\w/)) return -1;
const end = cx.slice(pos, cx.end).search(/\s/); const end = cx.slice(pos, cx.end).search(/\s/);
return cx.addElement(cx.elt('Tag', pos, end === -1 ? cx.end : end, [ cx.elt('TagMeta', pos, pos + 1) ])); return cx.addElement(cx.elt('Tag', pos, end === -1 ? cx.end : pos + end));
}, },
}], }],
props: [ props: [
styleTags({ styleTags({
'Tag': tagTag, 'Tag': tagTag,
'TagMeta': tags.meta,
}) })
] ]
}; };

View File

@ -1,7 +1,9 @@
import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { EditorView } from '@codemirror/view';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { Element, MarkdownConfig } from '@lezer/markdown'; import type { Element, MarkdownConfig } from '@lezer/markdown';
import { styleTags, tags } from '@lezer/highlight'; import { styleTags, tags } from '@lezer/highlight';
import { Content } from '../content.util'; import { Content } from '../content.util';
import { selectAll } from 'hast-util-select';
function fuzzyMatch(text: string, search: string): number { function fuzzyMatch(text: string, search: string): number {
const textLower = text.toLowerCase().normalize('NFC'); const textLower = text.toLowerCase().normalize('NFC');
@ -23,10 +25,10 @@ function fuzzyMatch(text: string, search: string): number {
export const wikilink: MarkdownConfig = { export const wikilink: MarkdownConfig = {
defineNodes: [ defineNodes: [
'Wikilink', 'Wikilink', //Whole group
'WikilinkMeta', 'WikilinkMeta', //Meta characters ([[ & ]])
'WikilinkHref', 'WikilinkHref', //Link
'WikilinkTitle', 'WikilinkTitle', //Title (always visible)
], ],
parseInline: [{ parseInline: [{
name: 'Wikilink', name: 'Wikilink',
@ -41,6 +43,8 @@ export const wikilink: MarkdownConfig = {
const start = pos, children: Element[] = [], end = start + match[0].length; const start = pos, children: Element[] = [], end = start + match[0].length;
if(match[0] === '[[]]') return end;
children.push(cx.elt('WikilinkMeta', start, start + 2)); children.push(cx.elt('WikilinkMeta', start, start + 2));
if(match[1] && !match[2] && !match[3]) //Link only if(match[1] && !match[2] && !match[3]) //Link only
@ -89,32 +93,69 @@ export const wikilink: MarkdownConfig = {
}) })
] ]
}; };
export const autocompletion = (context: CompletionContext): CompletionResult | null => {
const word = context.matchBefore(/\[\[[\w\s-]*/);
if (!word || (word.from === word.to && !context.explicit))
return null;
const searchTerm = word.text.slice(2).toLowerCase(); export const autocompletion = (context: CompletionContext): CompletionResult | Promise<CompletionResult | null> | null => {
const header = context.matchBefore(/\[\[[^\[\]\|\#]+#[^\[\]\|\#]*/);
if(!header || (header.from === header.to && !context.explicit))
{
const word = context.matchBefore(/\[\[[\w\s-]*/);
if (!word || (word.from === word.to && !context.explicit)) return null;
const options = Object.values(Content.files).filter(e => e.type !== 'folder').map(e => ({ ...e, score: fuzzyMatch(e.title, searchTerm) })).filter(e => e.score > 0).sort((a, b) => b.score - a.score).slice(0, 50); const options = Object.values(Content.files).filter(e => e.type !== 'folder');
return { return {
from: word.from + 2, from: word.from + 2,
options: options.map(e => ({ options: options.map(e => ({
label: e.title, label: e.title,
detail: e.path, detail: e.path,
apply: (view, completion, from, to) => { apply: (view, completion, from, to) => {
view.dispatch({ const closed = view.state.sliceDoc(from, to + 2).endsWith(']]');
changes: { view.dispatch({
from: word.from, changes: {
to: word.to, from: from - 2,
insert: `[[${e.path}]]` to: to,
insert: closed ? `[[${completion.detail}` : `[[${completion.detail}]]`
},
selection: { anchor: from + (completion.detail?.length ?? 0) }
});
},
type: 'text',
})),
commitCharacters: ['#', '|'],
validFor: /^[\[\w\s-]*$/,
}
}
else
{
const path = header.text.match(/^\[\[([^\[\]\|\#]+)#/);
if(!path || !path[1]) return null;
const content = Content.getFromPath(path[1]);
if(!content || content.type !== 'markdown') return null;
return (async () => {
const headers = selectAll('h1, h2, h3, h4, h5, h6', await useMarkdown().parse((await Content.getContent(content.id))!.content as string));
return {
from: header.from + path[1]!.length + 3,
options: headers.map(e => ({
label: e.properties.id as string,
apply: (view, completion, from, to) => {
const closed = view.state.sliceDoc(from, to + 2).endsWith(']]');
view.dispatch({
changes: {
from: from,
to: to,
insert: closed ? `${completion.label}` : `${completion.label}]]`
},
selection: { anchor: from + (completion.label?.length ?? 0) }
});
}, },
selection: { anchor: word.from + e.path.length + 2 } type: 'text',
}); })),
}, commitCharacters: ['#', '|'],
type: 'text' validFor: new RegExp(`\\[\\[${path[1]}#[^\[\]\|\#]*`),
})), };
validFor: /^[\[\w\s-]*$/, })();
} }
}; };

View File

@ -4,7 +4,25 @@ import type { Localized } from "~/types/general";
const config = characterConfig as CharacterConfig; const config = characterConfig as CharacterConfig;
export function getText(id?: i18nID, lang?: keyof Localized): string let language: keyof Localized = 'fr_FR';
export function init()
{ {
return id ? (config.texts.hasOwnProperty(id) ? (config.texts[id] as Localized)[lang ?? "default"] ?? '' : '') : ''; language = localStorage.getItem('language') as keyof Localized ?? 'fr_FR';
}
export function setLang(lang: keyof Localized)
{
localStorage.setItem('language', lang);
language = lang;
}
export function getText(id?: i18nID): string
{
if(!id) return '';
if(!config.texts.hasOwnProperty(id)) return '';
if(!config.texts[id]!.hasOwnProperty(language)) return 'Untranslated';
else return config.texts[id]![language]!;
}
export function setText(id: i18nID, text: string)
{
if(!config.texts.hasOwnProperty(id)) config.texts[id] = {};
config.texts[id]![language] = text;
} }

View File

@ -61,9 +61,10 @@ export class Socket
{ {
this._handlers.set(type, callback); this._handlers.set(type, callback);
} }
public send(type: string, data: any) public send(type: string, data: any, trigger: boolean = false)
{ {
this._ws.readyState === WebSocket.OPEN && this._ws.send(JSON.stringify({ type, data })); this._ws.readyState === WebSocket.OPEN && this._ws.send(JSON.stringify({ type, data }));
trigger && this._handlers.has(type) && queueMicrotask(() => this._handlers.get(type)!(data));
} }
public close() public close()
{ {