Feature Builder panel progress

This commit is contained in:
Peaceultime 2025-08-11 09:39:41 +02:00
parent 86556ec604
commit 920ce2e1b6
23 changed files with 4924 additions and 4534 deletions

View File

@ -1,38 +0,0 @@
<template>
<template v-if="model && model.people !== undefined">
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10">
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Points restants</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-20 bg-transparent px-3 py-1 outline-none caret-light-50 dark:caret-dark-50" />
</NumberFieldRoot>
</Label>
<Button @click="emit('next')">Suivant</Button>
</div>
<div class="flex flex-row flex-wrap justify-center items-center flex-1 gap-12 mx-8 my-4 px-48">
<template v-for="ability of config.abilities">
<div class="flex flex-col border border-light-50 dark:border-dark-50 p-4 gap-2 w-[200px] relative">
<div class="flex justify-between">
<NumberFieldRoot :min="0" class="flex w-20 justify-center border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 hover:border-light-50 dark:hover:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
<NumberFieldInput class="tabular-nums w-20 bg-transparent px-3 py-1 outline-none caret-light-50 dark:caret-dark-50" />
</NumberFieldRoot>
<Tooltip side="bottom" :message="`${mainStatTexts[ability.max[0]]} (0) + ${mainStatTexts[ability.max[1]]} (0) + 0`"><span class="text-lg text-end cursor-pointer">/ {{ 0 }}</span></Tooltip>
</div>
<span class="text-xl text-center font-bold">{{ ability.name }}</span>
<span class="absolute -bottom-px -left-px h-[3px] bg-accent-blue" :style="{ width: `200px` }"></span>
</div>
</template>
</div>
</template>
</template>
<script setup lang="ts">
import { mainStatTexts, type Character, type CharacterConfig } from '~/types/character';
const { config } = defineProps<{
config: CharacterConfig,
}>();
const model = defineModel<Character>({ required: true });
const emit = defineEmits(['next']);
</script>

View File

@ -1,38 +0,0 @@
<template>
<template v-if="model && model.people !== undefined">
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10">
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Physique</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Mental</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Caractère</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Button @click="emit('next')" :disabled="model.aspect === undefined">Enregistrer</Button>
</div>
<div class="flex flex-col flex-1 gap-4 mx-8 my-4">
</div>
</template>
</template>
<script setup lang="ts">
import type { Character, CharacterConfig } from '~/types/character';
const { config } = defineProps<{
config: CharacterConfig,
}>();
const model = defineModel<Character>({ required: true });
const emit = defineEmits(['next']);
</script>

View File

@ -1,56 +0,0 @@
<template>
<template v-if="model && model.character && model.character.people !== undefined">
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10">
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Niveau</span>
<NumberFieldRoot :min="1" :max="20" v-model="model.character.level" @update:model-value="val => model.updateLevel(val as Level)" class="flex justify-center border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 hover:border-light-50 dark:hover:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
<NumberFieldInput class="tabular-nums w-20 bg-transparent px-3 py-1 outline-none caret-light-50 dark:caret-dark-50" />
</NumberFieldRoot>
</Label>
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Attributions restantes</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Vie</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Mana</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Button @click="emit('next')">Suivant</Button>
</div>
<div class="flex flex-col flex-1 gap-4 mx-8 my-4">
<template v-for="(level, index) of config.peoples[model.character.people!].options">
<div class="w-full flex h-px"><div class="border-t border-dashed border-light-50 dark:border-dark-50 w-full" :class="{ 'opacity-30': index > model.character.level }"></div><span class="sticky top-0">{{ index }}</span></div>
<div class="flex flex-row gap-4 justify-center" :class="{ 'opacity-30': index > model.character.level }">
<template v-for="(option, i) of level">
<div class="flex border border-light-50 dark:border-dark-50 px-4 py-2 w-[400px]" @click="model.toggleLevelOption(parseInt(index as unknown as string, 10) as Level, i)"
:class="{ 'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer': index <= model.character.level, '!border-accent-blue bg-accent-blue bg-opacity-20': model.character.leveling?.some(e => e[0] == index && e[1] === i) ?? false }">
<span class="text-wrap whitespace-pre">{{ option.description }}</span>
</div>
</template>
</div>
</template>
</div>
</template>
</template>
<script setup lang="ts">
import type { CharacterBuilder } from '#shared/character.util';
import type { CharacterConfig, Level } from '~/types/character';
const { config } = defineProps<{
config: CharacterConfig,
}>();
const model = defineModel<CharacterBuilder>({ required: true });
const emit = defineEmits(['next']);
</script>

View File

@ -1,30 +0,0 @@
<template>
<template v-if="model">
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center">
<TextInput label="Nom" v-model="model.character.name" class="flex-none"/>
<Switch label="Privé ?" :default-value="model.character.visibility === 'private'" @update:model-value="(e) => model!.character.visibility = e ? 'private' : 'public'" />
<Button @click="emit('next')">Suivant</Button>
</div>
<div class="flex flex-1 gap-4 p-2 overflow-x-auto justify-center">
<div v-for="(people, i) of config.peoples" @click="model.character.people = i" class="flex flex-col flex-nowrap gap-2 p-2 border border-light-35 dark:border-dark-35
cursor-pointer hover:border-light-70 dark:hover:border-dark-70 w-[320px]" :class="{ '!border-accent-blue outline-2 outline outline-accent-blue': model.character.people === i }">
<Avatar :src="people.name" :text="`Image placeholder`" class="h-[320px]" />
<span class="text-xl font-bold text-center">{{ people.name }}</span>
<span class="w-full border-b border-light-50 dark:border-dark-50"></span>
<span class="text-wrap word-break">{{ people.description }}</span>
</div>
</div>
</template>
</template>
<script setup lang="ts">
import type { CharacterBuilder } from '#shared/character.util';
import type { CharacterConfig } from '~/types/character';
const { config } = defineProps<{
config: CharacterConfig,
}>();
const model = defineModel<CharacterBuilder>();
const emit = defineEmits(['next']);
</script>

View File

@ -1,69 +0,0 @@
<template>
<TrainingViewer :config="config">
<template #addin="{ stat }">
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10">
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Points restants</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Button @click="emit('next')">Suivant</Button>
</div>
</template>
<template #default="{ stat, level, option }">
<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" @click="toggleOption(stat, parseInt(level as unknown as string, 10) as TrainingLevel, option)" :class="{ /*'opacity-30': level > maxTraining[stat] + 1, 'hover:border-light-60 dark:hover:border-dark-60': level <= maxTraining[stat] + 1, */'!border-accent-blue bg-accent-blue bg-opacity-20': level == 0 || (model.training[stat]?.some(e => e[0] == level && e[1] === option) ?? false) }">
<MarkdownRenderer :proses="{ 'a': PreviewA }" :content="config.training[stat][level][option].description.map(e => e.text).join('\n')" />
</div>
</template>
</TrainingViewer>
</template>
<script setup lang="ts">
import PreviewA from '~/components/prose/PreviewA.vue';
import { MAIN_STATS, type Character, type CharacterConfig, type MainStat, type TrainingLevel } from '~/types/character';
const { config } = defineProps<{
config: CharacterConfig,
}>();
const model = defineModel<Character>({ required: true, });
const maxTraining = Object.fromEntries(MAIN_STATS.map(e => [e, 0]));
const emit = defineEmits(['next']);
function toggleOption(stat: MainStat, level: TrainingLevel, choice: number)
{
const character = model.value;
if(level == 0)
return;
for(let i = 1; i < level; i++) //Check previous levels as a requirement
{
if(!character.training[stat].some(e => e[0] == i))
return;
}
if(character.training[stat].some(e => e[0] == level))
{
if(character.training[stat].some(e => e[0] == level && e[1] === choice))
{
for(let i = 15; i >= level; i --) //Invalidate higher levels
{
const index = character.training[stat].findIndex(e => e[0] == i);
if(index !== -1)
character.training[stat].splice(index, 1);
}
}
else
character.training[stat].splice(character.training[stat].findIndex(e => e[0] == level), 1, [level, choice]);
}
else //if(trainingPoints.value && trainingPoints.value > 0)
{
character.training[stat].push([level, choice]);
}
model.value = character;
}
</script>

View File

@ -181,7 +181,7 @@ export const _useShortcuts = () => {
return false return false
}) })
onMounted(() => { tryOnMounted(() => {
metaSymbol.value = macOS.value ? '⌘' : 'Ctrl' metaSymbol.value = macOS.value ? '⌘' : 'Ctrl'
}) })

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -18,15 +18,16 @@
<NavigationMenuList class="flex items-center gap-8 max-md:hidden"> <NavigationMenuList class="flex items-center gap-8 max-md:hidden">
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuTrigger> <NavigationMenuTrigger>
<NuxtLink :href="{ name: 'character' }" class="text-light-70 dark:text-dark-70" active-class="!text-accent-blue"><span class="pl-3 py-1 flex-1 truncate">Personnages</span></NuxtLink> <NuxtLink :href="{ name: 'character' }" class="text-light-70 dark:text-dark-70 border-b-[2px] border-transparent hover:border-accent-blue py-2 hover:!text-opacity-70 flex items-center" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Personnages</span><Icon icon="radix-icons:caret-down" /></NuxtLink>
</NavigationMenuTrigger> </NavigationMenuTrigger>
<NavigationMenuContent class="absolute top-0 left-0 w-full sm:w-auto bg-light-0 dark:bg-dark-0 border border-light-30 dark:border-dark-30"> <NavigationMenuContent class="absolute top-0 w-full sm:w-auto bg-light-0 dark:bg-dark-0 border border-light-30 dark:border-dark-30 py-2 z-20 flex flex-col">
<NuxtLink :href="{ name: 'character-list' }" class="text-light-70 dark:text-dark-70" active-class="!text-accent-blue"><span class="py-2 px-3 flex-1 truncate">Tous les personnages</span></NuxtLink> <NuxtLink :href="{ name: 'character-list' }" class="text-light-70 dark:text-dark-70 hover:bg-light-10 dark:hover:bg-dark-10 hover:text-light-100 dark:hover:text-dark-100 py-2 px-4" active-class="!text-accent-blue"><span class="flex-1 truncate">Personnages publics</span></NuxtLink>
<NuxtLink :href="{ name: 'character-id-edit', params: { id: 'new' } }" class="text-light-70 dark:text-dark-70 hover:bg-light-10 dark:hover:bg-dark-10 hover:text-light-100 dark:hover:text-dark-100 py-2 px-4" active-class="!text-accent-blue"><span class="flex-1 truncate">Nouveau personnage</span></NuxtLink>
</NavigationMenuContent> </NavigationMenuContent>
</NavigationMenuItem> </NavigationMenuItem>
</NavigationMenuList> </NavigationMenuList>
<div class="absolute top-full left-0 flex w-full justify-center my-4"> <div class="absolute top-full left-0 flex w-full justify-center">
<NavigationMenuViewport class="h-[var(--radix-navigation-menu-viewport-height)] w-full origin-[top_center] overflow-hidden rounded-[10px] bg-white transition-[width,_height] duration-300 sm:w-[var(--radix-navigation-menu-viewport-width)]" /> <NavigationMenuViewport class="h-[var(--radix-navigation-menu-viewport-height)] w-full origin-[top_center] flex justify-center overflow-hidden sm:w-[var(--radix-navigation-menu-viewport-width)]" />
</div> </div>
</NavigationMenuRoot> </NavigationMenuRoot>
<div class="flex items-center px-2 gap-4"> <div class="flex items-center px-2 gap-4">
@ -90,26 +91,26 @@ const path = computed(() => route.value.params.path ? decodeURIComponent(unifySl
await Content.init(); await Content.init();
const tree = new TreeDOM((item, depth) => { const tree = new TreeDOM((item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private } }, [ return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private } }, [
icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 2 - 1.5}em` } }), icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 1.5 - 1}em` } }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }), dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
item.private ? popper(dom('span', { class: 'flex' }, [icon('radix-icons:lock-closed', { class: 'mx-1' })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }) : undefined, item.private ? popper(dom('span', { class: 'flex' }, [icon('radix-icons:lock-closed', { class: 'mx-1' })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }) : undefined,
])]); ])]);
}, (item, depth) => { }, (item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [link({ class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, active: 'text-accent-blue' }, item.path ? { name: 'explore-path', params: { path: item.path } } : undefined, [ return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [link({ class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, active: 'text-accent-blue' }, item.path ? { name: 'explore-path', params: { path: item.path } } : undefined, [
icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }), icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }), dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
item.private ? popper(dom('span', { class: 'flex' }, [icon('radix-icons:lock-closed', { class: 'mx-1' })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }) : undefined, item.private ? popper(dom('span', { class: 'flex' }, [icon('radix-icons:lock-closed', { class: 'mx-1' })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }) : undefined,
])]); ])]);
}, (item) => item.navigable); }, (item) => item.navigable);
(path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(e, true)); (path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(tree.tree.search('path', e)[0], true));
const treeParent = useTemplateRef('treeParent'); const treeParent = useTemplateRef('treeParent');
const unmount = useRouter().afterEach((to, from, failure) => { const unmount = useRouter().afterEach((to, from, failure) => {
if(failure) if(failure)
return; return;
to.name === 'explore-path' && (unifySlug(to.params.path).split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(e, true)); to.name === 'explore-path' && (unifySlug(to.params.path ?? '').split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(tree.tree.search('path', e)[0], true));
}); });
watch(route, () => { watch(route, () => {

View File

@ -16,7 +16,7 @@ onMounted(() => {
useShortcuts({ useShortcuts({
"Meta_S": () => builder.save(false), "Meta_S": () => builder.save(false),
}) });
} }
}); });
}) })

View File

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import characterConfig from '#shared/character-config.json';
import type { CharacterConfig } from '~/types/character';
definePageMeta({ definePageMeta({
guestsGoesTo: '/user/login', guestsGoesTo: '/user/login',
@ -8,6 +10,7 @@ const { add } = useToast();
const { user } = useUserSession(); const { user } = useUserSession();
const { data: characters, error, status } = await useFetch(`/api/character`); const { data: characters, error, status } = await useFetch(`/api/character`);
const config = characterConfig as CharacterConfig;
async function deleteCharacter(id: number) async function deleteCharacter(id: number)
{ {
@ -32,21 +35,48 @@ async function duplicateCharacter(id: number)
<Title>d[any] - Mes personnages</Title> <Title>d[any] - Mes personnages</Title>
</Head> </Head>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="flex align-center justify-center">
<NuxtLink v-if="user?.state === 1" :to="{ name: 'character-id-edit', params: { id: 'new' } }"><Button>Nouveau personnage</Button></NuxtLink>
<Tooltip v-else side="top" message="Veuillez valider votre email avant de pouvoir créer un personnage."><Button disabled>Nouveau personnage</Button></Tooltip>
</div>
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center"> <div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
<Loading size="large" /> <Loading size="large" />
</div> </div>
<div v-else-if="status === 'success'" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full"> <div v-else-if="status === 'success'" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
<div class="border border-light-30 dark:border-dark-30 p-3 flex flex-row gap-4" v-for="character of characters"> <div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
<Avatar size="large" icon="radix-icons:person" src="" /> <NuxtLink :to="{ name: 'character-id', params: { id: character.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2">
<div class="flex flex-1 flex-shrink flex-col truncate"> <div class="flex flex-row gap-8 ps-4 items-center">
<NuxtLink class="text-xl font-bold hover:text-accent-blue truncate" :to="{ name: 'character-id', params: { id: character.id } }" :title="character.name">{{ character.name }}</NuxtLink> <div class="flex flex-1 flex-col gap-2 justify-center">
<span class="text-sm truncate">Niveau {{ character.level }}</span> <span class="text-lg font-bold group-hover:text-accent-blue">{{ character.name }}</span>
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
<div class="flex flex-row flex-1 items-stretch gap-4">
<span class="text-sm">Niveau {{ character.level }}</span>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<span class="text-sm italic">{{ config.peoples[character.people!]?.name }}</span>
</div>
</div>
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
</div>
</NuxtLink>
<div class="flex justify-around items-center py-2 px-4 gap-4">
<NuxtLink :to="{ name: 'character-id-edit', params: { id: character.id } }" class="text-sm font-bold cursor-pointer hover:text-accent-blue">Editer</NuxtLink>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<NuxtLink @click="duplicateCharacter(character.id)" class="text-sm font-bold cursor-pointer hover:text-accent-blue">Dupliquer</NuxtLink>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<AlertDialogRoot>
<AlertDialogTrigger>
<span class="text-sm font-bold text-light-red dark:text-dark-red">Supprimer</span>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
<AlertDialogContent
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
<AlertDialogTitle class="text-3xl font-light relative -top-2">Supprimer {{ character.name }} ?</AlertDialogTitle>
<div class="flex flex-1 justify-end gap-4">
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
<AlertDialogAction asChild><Button @click="() => deleteCharacter(character.id)" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div> </div>
<AlertDialogRoot> <!--
<DropdownMenuRoot> <DropdownMenuRoot>
<DropdownMenuTrigger class="self-start"> <DropdownMenuTrigger class="self-start">
<Button icon><Icon icon="radix-icons:dots-vertical" /></Button> <Button icon><Icon icon="radix-icons:dots-vertical" /></Button>
@ -84,7 +114,7 @@ async function duplicateCharacter(id: number)
</div> </div>
</AlertDialogContent> </AlertDialogContent>
</AlertDialogPortal> </AlertDialogPortal>
</AlertDialogRoot> </AlertDialogRoot> -->
</div> </div>
</div> </div>
<div v-else> <div v-else>

View File

@ -1,42 +1,113 @@
<script setup lang="ts"> <script setup lang="ts">
import characterConfig from '#shared/character-config.json'; import characterConfig from '#shared/character-config.json';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import type { CharacterConfig } from '~/types/character'; import { FeatureEditor } from '~/shared/feature.util';
import { confirm, fullblocker } from '~/shared/floating.util';
import { getID, ID_SIZE } from '~/shared/general.util';
import type { CharacterConfig, Feature } from '~/types/character';
//@ts-ignore //@ts-ignore
const config = ref<CharacterConfig>(characterConfig); const config = ref<CharacterConfig>(characterConfig);
const featureEditor = new FeatureEditor();
function copy() function copy()
{ {
navigator.clipboard.writeText(JSON.stringify(config.value)); navigator.clipboard.writeText(JSON.stringify(config.value));
} }
function createFeature()
{
const feature: Feature = { id: getID(ID_SIZE), description: '', effect: [] };
featureEditor.edit(feature).then(feature => {
config.value.features[feature.id] = feature;
}).catch(() => {}).finally(() => {
setTimeout(popup.close, 150);
featureEditor.container.setAttribute('data-state', 'inactive');
});
const popup = fullblocker([featureEditor.container], {
priority: true, closeWhenOutside: false,
});
featureEditor.container.setAttribute('data-state', 'active');
}
function editFeature(id: string)
{
config.value.features[id] && featureEditor.edit(config.value.features[id]).then(feature => {
config.value.features[id] = feature;
}).catch(() => {}).finally(() => {
setTimeout(popup.close, 150);
featureEditor.container.setAttribute('data-state', 'inactive');
});
const popup = fullblocker([featureEditor.container], {
priority: true, closeWhenOutside: false,
});
featureEditor.container.setAttribute('data-state', 'active');
}
function deleteFeature(id: string)
{
confirm("Voulez vous vraiment supprimer cet effet ?").then(e => {
if(e)
{
const value = config.value;
delete value.features[id];
config.value = value;
}
});
}
</script> </script>
<template> <template>
<Head> <Head>
<Title>d[any] - Edition de données</Title> <Title>d[any] - Edition de données</Title>
</Head> </Head>
<TabsRoot class="flex flex-1 max-w-full flex-col gap-8 justify-start items-center px-8 w-full" default-value="training"> <TabsRoot class="flex flex-1 max-w-full flex-col gap-8 justify-start items-center px-8 w-full" default-value="features">
<TabsList class="flex flex-row gap-4 self-center relative px-4"> <TabsList class="flex flex-row gap-4 self-center relative px-4">
<TabsIndicator class="absolute left-0 h-[3px] bottom-0 w-[--radix-tabs-indicator-size] translate-x-[--radix-tabs-indicator-position] transition-[width,transform] duration-300 bg-accent-blue"></TabsIndicator> <TabsIndicator class="absolute left-0 h-[3px] bottom-0 w-[--radix-tabs-indicator-size] translate-x-[--radix-tabs-indicator-position] transition-[width,transform] duration-300 bg-accent-blue"></TabsIndicator>
<TabsTrigger value="peoples" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Peuples</TabsTrigger> <TabsTrigger value="peoples" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Peuples ({{ config.peoples.length }})</TabsTrigger>
<TabsTrigger value="training" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Entrainement</TabsTrigger> <TabsTrigger value="training" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Entrainement</TabsTrigger>
<TabsTrigger value="abilities" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Compétences</TabsTrigger> <TabsTrigger value="abilities" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Compétences ({{ Object.keys(config.abilities).length }})</TabsTrigger>
<TabsTrigger value="spells" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Sorts</TabsTrigger> <TabsTrigger value="aspects" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Aspects ({{ config.aspects.length }})</TabsTrigger>
<TabsTrigger value="spells" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Sorts ({{ config.spells.length }})</TabsTrigger>
<TabsTrigger value="features" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Features ({{ Object.keys(config.features).length }})</TabsTrigger>
<Tooltip message="Copier le JSON" side="right"><Button icon @click="copy" class="p-2"><Icon icon="radix-icons:clipboard-copy" /></Button></Tooltip> <Tooltip message="Copier le JSON" side="right"><Button icon @click="copy" class="p-2"><Icon icon="radix-icons:clipboard-copy" /></Button></Tooltip>
</TabsList> </TabsList>
<div class="flex-1 outline-none max-w-full w-full"> <div class="flex flex-row outline-none max-w-full w-full relative overflow-hidden">
<TabsContent value="peoples"> <div class="flex flex-1 outline-none max-w-full overflow-hidden">
</TabsContent> <TabsContent value="peoples" class="outline-none flex gap-4 flex-col overflow-hidden">
<TabsContent value="training"> <div class=""></div>
<TrainingConfigEditor :config="config" /> </TabsContent>
</TabsContent> <TabsContent value="training" class="outline-none flex gap-4 flex-col overflow-hidden">
<TabsContent value="abilities"> <div class=""></div>
</TabsContent>
</TabsContent> <TabsContent value="abilities" class="outline-none flex gap-4 flex-col overflow-hidden">
<TabsContent value="spells"> <div class=""></div>
</TabsContent>
</TabsContent> <TabsContent value="aspects" class="outline-none flex gap-4 flex-col overflow-hidden">
<div class=""></div>
</TabsContent>
<TabsContent value="spells" class="outline-none flex gap-4 flex-col overflow-hidden">
<div class=""></div>
</TabsContent>
<TabsContent value="features" class="outline-none flex gap-4 flex-col overflow-hidden">
<div class="flex flex-col w-full gap-2 justify-end items-end relative">
<Button icon @click="createFeature"><Icon icon="radix-icons:plus" class="w-6 h-6" /></Button>
</div>
<div class="flex flex-col gap-2 overflow-x-hidden pe-2">
<div class="flex flex-row gap-2 w-full border-b border-light-35 dark:border-dark-35 pb-2" v-for="feature of config.features">
<div class="w-full flex flex-row px-4 gap-8 items-center">
<span class="font-mono">{{ feature.id }}</span>
<span class="truncate">{{ feature.description }}</span>
</div>
<div class="flex flex-row gap-2 items-center">
<Button icon @click="editFeature(feature.id)"><Icon icon="radix-icons:pencil-1" /></Button>
<Button icon @click="deleteFeature(feature.id)"><Icon icon="radix-icons:trash" /></Button>
</div>
</div>
</div>
</TabsContent>
</div>
</div> </div>
</TabsRoot> </TabsRoot>
</template> </template>

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import type { Ability, Alignment, Character, CharacterConfig, CompiledCharacter, DoubleIndex, Feature, FeatureItem, Level, MainStat, SpellElement, SpellType, TrainingLevel } from "~/types/character"; import type { Ability, Alignment, Character, CharacterConfig, CompiledCharacter, DoubleIndex, Feature, FeatureItem, Level, MainStat, SpellElement, SpellType, TrainingLevel } from "~/types/character";
import { z } from "zod/v4"; import { z } from "zod/v4";
import characterConfig from './character-config.json'; import characterConfig from './character-config.json';
import { button, fakeA, loading } from "./proses"; import { button, fakeA, input, loading } from "./proses";
import { div, dom, icon, text } from "./dom.util"; import { div, dom, icon, text } from "./dom.util";
import { popper } from "./floating.util"; import { popper } from "./floating.util";
import { clamp } from "./general.util"; import { clamp } from "./general.util";
@ -83,7 +83,6 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
precision: 0, precision: 0,
arts: 0, arts: 0,
}, },
spells: character.spells ?? [],
speed: false, speed: false,
defense: { defense: {
hardcap: Infinity, hardcap: Infinity,
@ -105,8 +104,16 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
magicinstinct: 0, magicinstinct: 0,
}, },
bonus: {}, bonus: {},
resistance: {},//Object.fromEntries(MAIN_STATS.map(e => [e as MainStat, [0, 0]])) as Record<MainStat, [number, number]>, resistance: {},
initiative: 0, initiative: 0,
capacity: 0,
lists: {
action: [],
freeaction: [],
reaction: [],
passive: [],
spells: character.spells,
},
aspect: "", aspect: "",
notes: character.notes ?? "", notes: character.notes ?? "",
}); });
@ -120,6 +127,15 @@ export const mainStatTexts: Record<MainStat, string> = {
"charisma": "Charisme", "charisma": "Charisme",
"psyche": "Psyché", "psyche": "Psyché",
}; };
export const mainStatShortTexts: Record<MainStat, string> = {
"strength": "FOR",
"dexterity": "DEX",
"constitution": "CON",
"intelligence": "INT",
"curiosity": "CUR",
"charisma": "CHA",
"psyche": "PSY",
};
export const elementTexts: Record<SpellElement, { class: string, text: string }> = { export const elementTexts: Record<SpellElement, { class: string, text: string }> = {
fire: { class: 'text-light-red dark:text-dark-red border-light-red dark:border-dark-red bg-light-red dark:bg-dark-red', text: 'Feu' }, fire: { class: 'text-light-red dark:text-dark-red border-light-red dark:border-dark-red bg-light-red dark:bg-dark-red', text: 'Feu' },
@ -176,7 +192,7 @@ const stepTexts: Record<number, string> = {
4: 'Déterminez l\'Aspect qui vous corresponds et benéficiez de puissants bonus.' 4: 'Déterminez l\'Aspect qui vous corresponds et benéficiez de puissants bonus.'
}; };
type Property = { value: number | string, operation: "set" | "add" }; type Property = { value: number | string | false, id: string, operation: "set" | "add" };
type PropertySum = { list: Array<Property>, value: number, _dirty: boolean }; type PropertySum = { list: Array<Property>, value: number, _dirty: boolean };
export class CharacterBuilder export class CharacterBuilder
{ {
@ -213,15 +229,10 @@ export class CharacterBuilder
this._result = defaultCompiledCharacter(this._character); this._result = defaultCompiledCharacter(this._character);
Object.entries(character.leveling).forEach(e => { Object.entries(character.leveling).forEach(e => this.add(people.options[parseInt(e[0]) as Level][e[1]]!));
const feature = people.options[parseInt(e[0]) as Level][e[1]]!;
feature.effect.map(e => this.apply(e));
});
MAIN_STATS.forEach(stat => { MAIN_STATS.forEach(stat => {
Object.entries(character.training[stat]).forEach(option => { Object.entries(character.training[stat]).forEach(option => this.add(config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]]))
config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]]!.features?.forEach(this.apply.bind(this));
})
}); });
} }
load.remove(); load.remove();
@ -372,7 +383,7 @@ export class CharacterBuilder
{ {
if(typeof buffer.list[i]!.value === 'string') if(typeof buffer.list[i]!.value === 'string')
{ {
if(this._buffer[buffer.list[i]!.value]!._dirty) if(this._buffer[buffer.list[i]!.value as string]!._dirty)
{ {
//Put it back in queue since its dependencies haven't been resolved yet //Put it back in queue since its dependencies haven't been resolved yet
queue.push(property); queue.push(property);
@ -381,9 +392,9 @@ export class CharacterBuilder
else else
{ {
if(buffer.list[i]?.operation === 'add') if(buffer.list[i]?.operation === 'add')
sum += this._buffer[buffer.list[i]!.value]!.value; sum += this._buffer[buffer.list[i]!.value as string]!.value;
else if(buffer.list[i]?.operation === 'set') else if(buffer.list[i]?.operation === 'set')
sum = this._buffer[buffer.list[i]!.value]!.value; sum = this._buffer[buffer.list[i]!.value as string]!.value;
} }
} }
else else
@ -478,31 +489,57 @@ export class CharacterBuilder
{ {
if(this._character.training[stat].hasOwnProperty(i)) if(this._character.training[stat].hasOwnProperty(i))
{ {
config.training[stat][i as TrainingLevel][this._character.training[stat][i as TrainingLevel]!]?.features?.forEach(this.undo.bind(this)); this.remove(config.training[stat][i as TrainingLevel][this._character.training[stat][i as TrainingLevel]!]);
delete this._character.training[stat][i as TrainingLevel]; delete this._character.training[stat][i as TrainingLevel];
} }
} }
} }
else else
{ {
config.training[stat][level][this._character.training[stat][level]!]?.features?.forEach(this.undo.bind(this)); this.remove(config.training[stat][level][this._character.training[stat][level]!]);
this._character.training[stat][level] = choice; this._character.training[stat][level] = choice;
config.training[stat][level][choice]?.features?.forEach(this.apply.bind(this)); this.add(config.training[stat][level][choice]);
} }
} }
else else
{ {
this._character.training[stat][level] = choice; this._character.training[stat][level] = choice;
config.training[stat][level][choice]?.features?.forEach(this.apply.bind(this)); this.add(config.training[stat][level][choice]);
} }
} }
private add(feature?: Feature) private add(feature?: string)
{ {
feature?.effect.forEach(this.apply.bind(this)); if(!feature)
return;
config.features[feature]?.effect.forEach(this.apply.bind(this));
} }
private remove(feature?: Feature) private remove(feature?: string)
{ {
feature?.effect.forEach(this.undo.bind(this)); if(!feature)
return;
config.features[feature]?.effect.forEach(this.undo.bind(this));
}
private choose(id: string, choices: number[])
{
const current = this._character.choices[id];
const [ feature, effect ] = id.split('-');
const option = config.features[feature!]!.effect.find(e => e.id === effect);
if(option?.category === 'choice')
{
if(current !== undefined)
{
current.forEach(e => this.undo(option.options[e]));
}
if(choices.length > 0)
{
choices.forEach(e => this.apply(option.options[e]));
}
this._character.choices[id] = choices;
}
} }
private apply(feature?: FeatureItem) private apply(feature?: FeatureItem)
{ {
@ -511,30 +548,26 @@ export class CharacterBuilder
switch(feature.category) switch(feature.category)
{ {
case "feature":
this._result.features[feature.kind] ??= [];
this._result.features[feature.kind]!.push(feature.text);
return;
case "list": case "list":
if(feature.action === 'add' && !this._result[feature.list].includes(feature.item)) if(feature.action === 'add' && !this._result.lists[feature.list]!.includes(feature.item))
this._result[feature.list].push(feature.item); this._result.lists[feature.list]!.push(feature.item);
else else
this._result[feature.list] = this._result[feature.list].filter((e: string) => e !== feature.item); this._result.lists[feature.list] = this._result.lists[feature.list]!.filter((e: string) => e !== feature.item);
return; return;
case "value": case "value":
this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true }; this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true };
this._buffer[feature.property]!.list.push({ operation: feature.operation, value: feature.value }); this._buffer[feature.property]!.list.push({ operation: feature.operation, id: feature.id, value: feature.value });
this._buffer[feature.property]!._dirty = true; this._buffer[feature.property]!._dirty = true;
return; return;
case "choice": case "choice":
const choice = this._character.choices[feature.id]!; const choice = this._character.choices[feature.id];
choice.forEach(e => this.apply(feature.options[e]!));
if(choice)
choice.forEach(e => this.apply(feature.options[e]!));
return; return;
default: default:
@ -548,29 +581,26 @@ export class CharacterBuilder
switch(feature.category) switch(feature.category)
{ {
case "feature":
this._result.features[feature.kind] = this._result.features[feature.kind]!.filter(e => e !== feature.text);
return;
case "list": case "list":
if(feature.action === 'remove' && !this._result[feature.list].includes(feature.item)) if(feature.action === 'remove' && !this._result.lists[feature.list]!.includes(feature.item))
this._result[feature.list].push(feature.item); this._result.lists[feature.list]!.push(feature.item);
else else
this._result[feature.list] = this._result[feature.list].filter(e => e !== feature.item); this._result.lists[feature.list] = this._result.lists[feature.list]!.filter(e => e !== feature.item);
return; return;
case "value": case "value":
this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true }; this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true };
this._buffer[feature.property]!.list.splice(this._buffer[feature.property]!.list.findIndex(e => e.operation === feature.operation && e.value === feature.value), 1); this._buffer[feature.property]!.list.splice(this._buffer[feature.property]!.list.findIndex(e => e.id === feature.id), 1);
this._buffer[feature.property]!._dirty = true; this._buffer[feature.property]!._dirty = true;
return; return;
case "choice": case "choice":
const choice = this._character.choices[feature.id]!; const choice = this._character.choices[feature.id];
choice.forEach(e => this.undo(feature.options[e]!));
delete this._character.choices[feature.id]; if(choice)
choice.forEach(e => this.undo(feature.options[e]!));
return; return;
default: default:
@ -599,14 +629,12 @@ class PeoplePicker implements BuilderTab
{ {
this._builder = builder; this._builder = builder;
this._nameInput = dom("input", { class: `mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 this._nameInput = input('text', {
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 input: (value) => {
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20`, listeners: { this._builder.character.name = value ?? '';
input: (e: Event) => {
this._builder.character.name = this._nameInput.value ?? '';
document.title = `d[any] - Edition de ${this._builder.character.name || 'nouveau personnage'}`; document.title = `d[any] - Edition de ${this._builder.character.name || 'nouveau personnage'}`;
} }
}}); });
this._visibilityInput = dom("div", { class: `group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none this._visibilityInput = dom("div", { class: `group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none
data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40 data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40
data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, attributes: { "data-state": "unckecked" }, listeners: { data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, attributes: { "data-state": "unckecked" }, listeners: {
@ -709,7 +737,7 @@ class LevelPicker implements BuilderTab
div("flex flex-row gap-4 justify-center", level[1].map((option, j) => 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 => { div("flex flex-row gap-4 justify-center", level[1].map((option, j) => 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: option.description }), option.effect.some(e => e.category === 'choice') ? div('absolute -bottom-px -right-px border border-light-50 dark:border-dark-50 bg-light-10 dark:bg-dark-10 hover:border-light-70 dark:hover:border-dark-70 flex p-1 justify-center items-center', [ icon('radix-icons:gear') ]) : undefined ]))) }}}, [ dom('span', { class: "text-wrap whitespace-pre", text: config.features[option]!.description }), config.features[option]!.effect.some(e => e.category === 'choice') ? div('absolute -bottom-px -right-px border border-light-50 dark:border-dark-50 bg-light-10 dark:bg-dark-10 hover:border-light-70 dark:hover:border-dark-70 flex p-1 justify-center items-center', [ icon('radix-icons:gear') ]) : undefined ])))
]); ]);
this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center", [ this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center", [
@ -788,7 +816,7 @@ class TrainingPicker implements BuilderTab
div("flex flex-row gap-4 justify-center", level[1].map((option, j) => 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 => { div("flex flex-row gap-4 justify-center", level[1].map((option, j) => 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 => {
this._builder.toggleTrainingOption(stat, parseInt(level[0]) as TrainingLevel, j); this._builder.toggleTrainingOption(stat, parseInt(level[0]) as TrainingLevel, j);
this.update(); this.update();
}}}, [ markdownUtil(option.description.map(e => e.text).join('\n'), undefined, { tags: { a: fakeA } }) ]))) }}}, [ markdownUtil(config.features[option]!.description, undefined, { tags: { a: fakeA } }) ])))
]) ])
} }
this._builder = builder; this._builder = builder;

View File

@ -704,7 +704,7 @@ export class Editor
{ {
e.preventDefault(); e.preventDefault();
const close = contextmenu(e.clientX, e.clientY, [ const { close } = contextmenu(e.clientX, e.clientY, [
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.add("markdown", item); close() }} }, [icon('radix-icons:plus'), text('Ajouter')]), dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.add("markdown", item); close() }} }, [icon('radix-icons:plus'), text('Ajouter')]),
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.rename(item); close() }} }, [icon('radix-icons:input'), text('Renommer')]), dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.rename(item); close() }} }, [icon('radix-icons:input'), text('Renommer')]),
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-red dark:text-dark-red', listeners: { click: (e) => { close(); confirm(`Confirmer la suppression de ${item.title}${item.children ? ' et de ses enfants' : ''} ?`).then(e => { if(e) this.remove(item)}) }} }, [icon('radix-icons:trash'), text('Supprimer')]), dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-red dark:text-dark-red', listeners: { click: (e) => { close(); confirm(`Confirmer la suppression de ${item.title}${item.children ? ' et de ses enfants' : ''} ?`).then(e => { if(e) this.remove(item)}) }} }, [icon('radix-icons:trash'), text('Supprimer')]),

View File

@ -11,7 +11,7 @@ type Listener<K extends keyof HTMLElementEventMap> = | ((ev: HTMLElementEventMap
export interface NodeProperties export interface NodeProperties
{ {
attributes?: Record<string, string | undefined | boolean>; attributes?: Record<string, string | undefined | boolean | number>;
text?: string; text?: string;
class?: Class; class?: Class;
style?: Record<string, string | undefined | boolean | number> | string; style?: Record<string, string | undefined | boolean | number> | string;
@ -30,7 +30,7 @@ export function dom<K extends keyof HTMLElementTagNameMap>(tag: K, properties?:
if(properties?.attributes) if(properties?.attributes)
for(const [k, v] of Object.entries(properties.attributes)) for(const [k, v] of Object.entries(properties.attributes))
if(typeof v === 'string') element.setAttribute(k, v); if(typeof v === 'string' || typeof v === 'number') element.setAttribute(k, v.toString(10));
else if(typeof v === 'boolean') element.toggleAttribute(k, v); else if(typeof v === 'boolean') element.toggleAttribute(k, v);
if(properties?.text) if(properties?.text)
@ -113,20 +113,24 @@ export interface IconProperties
style?: Record<string, string | undefined> | string; style?: Record<string, string | undefined> | string;
class?: Class; class?: Class;
} }
const iconCache: Map<IconProperties & { name: string }, HTMLElement> = new Map(); const iconCache: Map<string, HTMLElement> = new Map();
export function icon(name: string, properties?: IconProperties): HTMLElement export function icon(name: string, properties?: IconProperties): HTMLElement
{ {
const key = { ...properties, name }; let el;
if(iconCache.has(key)) if(iconCache.has(name))
return iconCache.get(key)!.cloneNode() as HTMLElement; el = iconCache.get(name)!.cloneNode() as HTMLElement;
else
{
el = document.createElement('iconify-icon');
const el = document.createElement('iconify-icon'); if(!iconExists(name))
loadIcon(name);
el.setAttribute('icon', name);
if(!iconExists(name)) iconCache.set(name, el.cloneNode() as HTMLElement);
loadIcon(name); }
el.setAttribute('icon', name);
properties?.mode && el.setAttribute('mode', properties?.mode.toString()); properties?.mode && el.setAttribute('mode', properties?.mode.toString());
properties?.inline && el.toggleAttribute('inline', properties?.inline); properties?.inline && el.toggleAttribute('inline', properties?.inline);
@ -150,7 +154,6 @@ export function icon(name: string, properties?: IconProperties): HTMLElement
for(const [k, v] of Object.entries(properties.style)) if(v !== undefined) el.attributeStyleMap.set(k, v); for(const [k, v] of Object.entries(properties.style)) if(v !== undefined) el.attributeStyleMap.set(k, v);
} }
iconCache.set(key, el.cloneNode() as HTMLElement);
return el; return el;
} }

View File

@ -9,13 +9,12 @@ import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { IterMode, Tree } from '@lezer/common'; import { IterMode, Tree } from '@lezer/common';
import { tags } from '@lezer/highlight'; import { tags } from '@lezer/highlight';
import { dom } from './dom.util'; import { dom } from './dom.util';
const External = Annotation.define<boolean>(); const External = Annotation.define<boolean>();
const Hidden = Decoration.mark({ class: 'hidden' }); const Hidden = Decoration.mark({ class: 'hidden' });
const Bullet = Decoration.mark({ class: '*:hidden before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4' }); const Bullet = Decoration.mark({ class: '*:hidden before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4' });
const Blockquote = Decoration.line({ class: '*:hidden before:block !ps-4 relative before:absolute before:top-0 before:bottom-0 before:left-0 before:w-1 before:bg-none before:bg-light-30 dark:before:bg-dark-30' }); const Blockquote = Decoration.line({ class: '*:hidden before:block !ps-4 relative before:absolute before:top-0 before:bottom-0 before:left-0 before:w-1 before:bg-none before:bg-light-30 dark:before:bg-dark-30' });
const TagTag = tags.special(tags.content);
const intersects = (a: { const intersects = (a: {
from: number; from: number;
to: number; to: number;
@ -36,7 +35,6 @@ const highlight = HighlightStyle.define([
{ tag: tags.strong, fontWeight: "bold" }, { tag: tags.strong, fontWeight: "bold" },
{ tag: tags.strikethrough, textDecoration: "line-through" }, { tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: tags.keyword, color: "#708" }, { tag: tags.keyword, color: "#708" },
{ tag: TagTag, class: 'cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30' }
]); ]);
class Decorator class Decorator
@ -111,29 +109,7 @@ export class MarkdownEditor
this.view = new EditorView({ this.view = new EditorView({
extensions: [ extensions: [
markdown({ markdown({
base: markdownLanguage, base: markdownLanguage
extensions: {
defineNodes: [
{ name: "Tag", style: TagTag },
{ name: "TagMark", style: tags.processingInstruction }
],
parseInline: [{
name: "Tag",
parse(cx, next, pos) {
if (next != 35 || cx.char(pos + 1) == 35) return -1;
let elts = [cx.elt("TagMark", pos, pos + 1)];
for (let i = pos + 1; i < cx.end; i++) {
let next = cx.char(i);
if (next == 35)
return cx.addElement(cx.elt("Tag", pos, i + 1, elts.concat(cx.elt("TagMark", i, i + 1))));
if (next == 92)
elts.push(cx.elt("Escape", i, i++ + 2));
if (next == 32 || next == 9 || next == 10 || next == 13) break;
}
return -1
}
}],
}
}), }),
history(), history(),
search(), search(),

318
shared/feature.util.ts Normal file
View File

@ -0,0 +1,318 @@
import type { Ability, CharacterConfig, Feature, FeatureEffect, FeatureItem, MainStat, TrainingOption } from "~/types/character";
import { div, dom, icon, text, type NodeChildren } from "./dom.util";
import { MarkdownEditor } from "./editor.util";
import { button, combobox, fakeA, input, numberpicker, select } from "./proses";
import { popper, tooltip } from "./floating.util";
import { mainStatShortTexts, mainStatTexts } from "./character.util";
import config from "#shared/character-config.json";
import { getID, ID_SIZE } from "./general.util";
import renderMarkdown from "./markdown.util";
export class FeatureEditor
{
private _container: HTMLDivElement;
private _success?: Function;
private _failure?: Function;
private _feature?: Feature;
private _idInput: HTMLInputElement;
private _table: HTMLDivElement;
constructor()
{
this._idInput = dom("input", { attributes: { 'disabled': true }, class: `mx-4 text-light-70 dark:text-dark-70 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-25 dark:bg-dark-25 border-light-30 dark:border-dark-30` });
this._table = div('grid grid-cols-2 gap-4 px-2');
this._container = 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', [
tooltip(button(icon('radix-icons:check', { width: 20, height: 20 }), () => {
this._success!(this._feature);
MarkdownEditor.singleton.onChange = undefined;
}, 'p-1'), 'Valider', 'left'),
dom('label', { class: 'flex justify-center items-center my-2' }, [
dom('span', { class: 'pb-1 md:p-0', text: "ID" }),
this._idInput
]),
tooltip(button(icon('radix-icons:cross-1', { width: 20, height: 20 }), () => {
this._failure!(this._feature);
MarkdownEditor.singleton.onChange = undefined;
}, 'p-1'), 'Annuler', 'left'),
]),
dom('span', { class: 'flex flex-col justify-start items-start my-2 gap-4' }, [
div('flex w-full items-center justify-between', [
dom('span', { class: 'pb-1 md:p-0', text: "Description" }),
tooltip(button(icon('radix-icons:clipboard', { width: 20, height: 20 }), () => {
MarkdownEditor.singleton.content = this._feature?.effect.map(e => textFromEffect(e)).join('\n') ?? this._feature?.description ?? MarkdownEditor.singleton.content;
}, '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('flex flex-col gap-2 w-full', [
div('flex flex-row justify-between', [
dom('h3', { class: 'text-lg font-bold', text: 'Effets' }),
tooltip(button(icon('radix-icons:plus', { width: 20, height: 20 }), () => {
this._table.appendChild(this._edit({ id: getID(ID_SIZE) }));
}, 'p-1'), 'Ajouter', 'left'),
]),
this._table,
])
]);
}
edit(feature: Feature): Promise<Feature>
{
return new Promise((success, failure) => {
this._success = success;
this._failure = failure;
this._feature = JSON.parse(JSON.stringify(feature)) as Feature;
this._table.replaceChildren(...this._feature.effect.map(this._renderEffect.bind(this)));
this._idInput.value = this._feature.id;
MarkdownEditor.singleton.onChange = (e) => this._feature!.description = e;
MarkdownEditor.singleton.content = this._feature.description;
});
}
private _renderEffect(effect: FeatureItem): HTMLDivElement
{
const content = div('border border-light-30 dark:border-dark-30 col-span-1', [ div('flex justify-between items-stretch', [
div('px-4 flex items-center h-full', [ renderMarkdown(textFromEffect(effect), undefined, { tags: { a: fakeA } }) ]),
div('flex', [ tooltip(button(icon('radix-icons:pencil-1'), () => {
this._table.replaceChild(this._edit(effect), content);
}, 'p-2 -m-px border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Modifier", "bottom"), tooltip(button(icon('radix-icons:trash'), () => {
this._feature!.effect = this._feature!.effect.filter(e => e.id !== effect.id);
content.remove();
}, 'p-2 -m-px border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Supprimer", "bottom") ])
]) ]);
return content;
}
private _edit(effect: FeatureItem): HTMLDivElement
{
const match = (effect: FeatureItem): Partial<FeatureItem> | undefined => {
switch(effect.category)
{
case 'value':
return choices.find(e => e.value.category === 'value' && e.value.property === effect.property)?.value;
/* case 'choice':
return choices.find(e => e.value.category === 'choice' && e.value. === effect.property); */
case 'list':
return choices.find(e => e.value.category === 'list' && e.value.list === effect.list)?.value;
}
};
const approve = () => {
const idx = this._feature!.effect.findIndex(e => e.id === buffer.id);
if(idx === -1)
this._feature!.effect.push(buffer);
else
this._feature!.effect[idx] = buffer;
this._table.replaceChild(this._renderEffect(buffer), content);
}, reject = () => {
const idx = this._feature!.effect.findIndex(e => e.id === buffer.id);
if(idx === -1)
content.remove();
else
this._table.replaceChild(this._renderEffect(effect), content);
}
let buffer = JSON.parse(JSON.stringify(effect)) as FeatureItem;
const redraw = () => {
let top: NodeChildren = [], bottom: NodeChildren = [];
switch(buffer.category)
{
case 'value':
const summaryText = text(textFromEffect(buffer));
top = [
select([ { text: '+', value: 'add' }, ['speed', 'capacity'].includes(buffer.property) ? { text: '=', value: 'set' } : undefined ], { defaultValue: buffer.operation, change: (value) => { (buffer as Extract<FeatureEffect, { category: "value" }>).operation = value as 'add' | 'set'; summaryText.textContent = textFromEffect(buffer); }, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px h-[36px]' } }),
typeof buffer.value === 'number' ? numberpicker({ defaultValue: buffer.value, input: (value) => { (buffer as Extract<FeatureEffect, { category: "value" }>).value = value; summaryText.textContent = textFromEffect(buffer); }, class: 'bg-light-25 dark:bg-dark-25 !-m-px h-[36px]' }) : select<`modifier/${MainStat}` | false>([...Object.entries(mainStatShortTexts).map(e => ({ text: 'Mod. de ' + e[1], value: `modifier/${e[0]}` as `modifier/${MainStat}` })), buffer.operation === 'add' ? undefined : { text: 'Interdit', value: false }], { class: { container: 'w-[160px] bg-light-25 dark:bg-dark-25 !-m-px h-[36px]' }, defaultValue: buffer.value, change: (value) => { (buffer as Extract<FeatureEffect, { category: "value" }>).value = value; summaryText.textContent = textFromEffect(buffer); } }),
button(icon('radix-icons:update'), () => {
(buffer as Extract<FeatureEffect, { category: "value" }>).value = (typeof (buffer as Extract<FeatureEffect, { category: "value" }>).value === 'number' ? '' as any as false : 0);
const element = redraw();
this._table.replaceChild(element, content);
content = element;
summaryText.textContent = textFromEffect(buffer);
}, 'px-2 -m-px border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'),
];
bottom = [summaryText];
break;
case 'list':
if(buffer.action === 'add')
{
if(buffer.list === 'spells')
{
bottom = [ combobox(config.spells.map(e => ({ text: e.name, value: e.id })), { defaultValue: buffer.item, change: (value) => (buffer as Extract<FeatureEffect, { category: "list" }>).item = value, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px h-[36px]' } }) ];
}
else
{
const editor = new MarkdownEditor();
editor.content = buffer.item;
editor.onChange = (item) => (buffer as Extract<FeatureEffect, { category: "list" }>).item = item;
bottom = [ div('px-4 py-1', [ editor.dom ]) ];
}
}
top = [ select([ { text: 'Ajouter', value: 'add' }, { text: 'Supprimer', value: 'remove' } ], { defaultValue: buffer.action, change: (value) => (buffer as Extract<FeatureEffect, { category: "list" }>).action = value as 'add' | 'remove', class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px h-[36px] w-32' } }) ];
break;
default: break;
}
return div('border border-light-30 dark:border-dark-30 col-span-1 row-span-2', [ div('flex justify-between items-stretch', [
div('flex flex-row', [
combobox(choices, { defaultValue: match(buffer), class: { container: 'bg-light-25 dark:bg-dark-25 w-[250px] -m-px h-[36px]' }, change: (e) => {
buffer = { id: buffer.id, ...e } as FeatureItem;
const element = redraw();
this._table.replaceChild(element, content);
content = element;
} }),
...top,
]),
div('flex', [ tooltip(button(icon('radix-icons:check'), approve, 'p-2 -m-px border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Valider", "bottom"), tooltip(button(icon('radix-icons:cross-1'), reject, 'p-2 -m-px border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Annuler", "bottom") ])
]), div('flex border-t border-light-35 dark:border-dark-35 max-h-[300px] min-h-[36px] overflow-auto', bottom) ]);
}
let content = redraw();
return content;
}
get container()
{
return this._container;
}
}
const choices: Array<{ text: string, value: Partial<FeatureItem> }> = [
{ text: 'PV max', value: { category: 'value', property: 'health', operation: 'add', value: 0 }, },
{ text: 'Mana max', value: { category: 'value', property: 'mana', operation: 'add', value: 0 }, },
{ text: 'Nombre de sorts maitrisés', value: { category: 'value', property: 'spellslots', operation: 'add', value: 0 }, },
{ text: 'Nombre d\'œuvres maitrisés', value: { category: 'value', property: 'artslots', operation: 'add', value: 0 }, },
{ text: 'Vitesse de course', value: { category: 'value', property: 'speed', operation: 'add', value: 0 }, },
{ text: 'Poids max', value: { category: 'value', property: 'capacity', operation: 'add', value: 0 }, },
{ text: 'Initiative', value: { category: 'value', property: 'initiative', operation: 'add', value: 0 }, },
{ text: 'Points d\'entrainement', value: { category: 'value', property: 'training', operation: 'add', value: 0 }, },
{ text: 'Points de compétence', value: { category: 'value', property: 'ability', operation: 'add', value: 0 }, },
{ text: 'Sort bonus', value: { category: 'list', list: 'spells', action: 'add' }, },
{ text: 'Action', value: { category: 'list', list: 'action', action: 'add' }, },
{ text: 'Réaction', value: { category: 'list', list: 'reaction', action: 'add' }, },
{ text: 'Action libre', value: { category: 'list', list: 'freeaction', action: 'add' }, },
{ text: 'Passif', value: { category: 'list', list: 'passive', action: 'add' }, },
{ text: 'Choix', value: { category: 'choice', options: [] }, },
];
function textFromEffect(effect: FeatureItem)
{
if(effect.category === 'value')
{
switch(effect.property)
{
case 'health':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' PV max.' } }) : textFromValue(effect.value, { prefix: { truely: 'PV max égal à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (PV = interdit).' });
case 'mana':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' mana max.' } }) : textFromValue(effect.value, { prefix: { truely: 'Mana max égal à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Mana = interdit).' });
case 'spellslots':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' sort(s) maitrisé(s).' } }) : textFromValue(effect.value, { prefix: { truely: 'Sorts maitrisés fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Sorts = interdit).' });
case 'artslots':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' œuvre(s) maitrisé(s).' } }) : textFromValue(effect.value, { prefix: { truely: 'Œuvres maitrisés fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Œuvres = interdit).' });
case 'speed':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' case(s) de course.' }, falsely: '+0 cases de course' }) : textFromValue(effect.value, { prefix: { truely: 'Vitesse de course de ' }, suffix: { truely: ' case(s).' }, falsely: 'Déplacement impossible.' });
case 'capacity':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' unité(s) d\'quipement.' } }) : textFromValue(effect.value, { prefix: { truely: 'Capacité d\'equipement fixé à ' }, suffix: { truely: ' unité(s).' }, falsely: 'Impossible de posséder du materiel.' });
case 'initiative':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' à l\'itiniative.' } }) : textFromValue(effect.value, { prefix: { truely: 'Initiative fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Initiative = interdit).' });
case 'training':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' point(s) d\'entrainement.' } }) : `Opération interdite (Entrainement fixe).`;
case 'ability':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' point(s) de compétence.' } }) : `Opération interdite (Compétences fixe).`;
default: break;
}
const splited = effect.property.split('/');
switch(splited[0])
{
case 'spellranks':
return '';
case 'defense':
switch(splited[1])
{
case 'hardcap':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Défense max ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Défense max fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Hardcap = interdit).' });
case 'static':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Base de défense ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Base de défense fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Static = interdit).' });
case 'activeparry':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Parade active ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Parade active fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Active parry = interdit).' });
case 'activedodge':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Esquive active ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Esquive active fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Active dodge = interdit).' });
case 'passiveparry':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Parade passive ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Parade passive fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Passive parry = interdit).' });
case 'passivedodge':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Esquive passive ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Esquive passive fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Passive dodge = interdit).' });
default: return 'Défense inconnue.';
}
case 'mastery':
switch(splited[1])
{
case 'strength':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Maitrise des armes (for.) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Maitrise des armes (for.) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise for = interdit).' });
case 'dexterity':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Maitrise des armes (dex.) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Maitrise des armes (dex.) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise dex = interdit).' });
case 'shield':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Maitrise des boucliers ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Maitrise des boucliers fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise boucliers = interdit).' });
case 'armor':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Maitrise des armure ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Maitrise des armure fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise armure = interdit).' });
case 'multiattack':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Attaque multiple ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Attaque multiple fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Attaque multiple = interdit).' });
case 'magicpower':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Puissance) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Puissance) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise puissance = interdit).' });
case 'magicspeed':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Rapidité) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Rapidité) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise rapidité = interdit).' });
case 'magicelement':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Elements) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Elements) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise elements = interdit).' });
case 'magicinstinct':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Instinct) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Instinct) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise instinct = interdit).' });
default: return 'Maitrise inconnue.';
}
/* case 'resistance':
return splited[1] ? config.resistances[splited[1] as string].name : 'résistance inconnue'; */
case 'abilities':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: `${config.abilities[splited[1] as Ability].name} `, positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: `${config.abilities[splited[1] as Ability].name} fixé à ` }, suffix: { truely: '.' }, falsely: `Echec automatique de ${`${config.abilities[splited[1] as Ability].name}.`}` });
case 'modifier':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+' }, suffix: { truely: ` au mod. de ${mainStatTexts[splited[1] as MainStat]}.` } }) : textFromValue(effect.value, { prefix: { truely: `Mod. de ${mainStatTexts[splited[1] as MainStat]} fixé à ` }, suffix: { truely: '.' }, falsely: `Opération interdite (Mod. de ${mainStatShortTexts[splited[1] as MainStat]} = interdit).` });
default: break;
}
return `Inconnu ("${effect.property}")`;
}
else if(effect.category === 'list')
{
switch(effect.list)
{
case 'action':
case 'reaction':
case 'freeaction':
case 'passive':
return effect.action === 'add' ? effect.item : 'Suppression d\'effet.';
case 'spells':
return effect.action === 'add' ? `Maitrise du sort "${config.spells.find(e => e.id === effect.item) ?? 'Sort inconnu'}".` : `Perte de maitrise du sort "${config.spells.find(e => e.id === effect.item) ?? 'Sort inconnu'}".`;
}
}
else if(effect.category === 'choice')
{
return `Choix (WIP)`;
}
else
{
return `Inconnu`;
}
}
function textFromValue(value: `modifier/${MainStat}` | number | false, settings?: {
prefix?: { text?: string, positive?: string, negative?: string, truely?: string },
suffix?: { text?: string, positive?: string, negative?: string, truely?: string },
falsely?: string
})
{
if(typeof value === 'string')
return `${settings?.prefix?.truely?.replaceAll('(s)', 's') ?? ''}${settings?.prefix?.text?.replaceAll('(s)', 's') ?? ''}${mainStatShortTexts[value.split('/')[1] as MainStat] ?? 'inconnu'}${settings?.suffix?.text?.replaceAll('(s)', 's') ?? ''}${settings?.suffix?.truely?.replaceAll('(s)', 's') ?? ''}`;
else if(value === false)
return settings?.falsely ?? '0';
else if(value >= 0)
return `${settings?.prefix?.truely?.replaceAll('(s)', value > 1 ? 's' : '') ?? ''}${settings?.prefix?.positive?.replaceAll('(s)', value > 1 ? 's' : '') ?? ''}${value.toString(10)}${settings?.suffix?.positive?.replaceAll('(s)', value > 1 ? 's' : '') ?? ''}${settings?.suffix?.truely?.replaceAll('(s)', value > 1 ? 's' : '') ?? ''}`;
else
return `${settings?.prefix?.truely?.replaceAll('(s)', value < -1 ? 's' : '') ?? ''}${settings?.prefix?.negative?.replaceAll('(s)', value < -1 ? 's' : '') ?? ''}${value.toString(10)}${settings?.suffix?.negative?.replaceAll('(s)', value < -1 ? 's' : '') ?? ''}${settings?.suffix?.truely?.replaceAll('(s)', value < -1 ? 's' : '') ?? ''}`;
}

View File

@ -8,12 +8,13 @@ export interface ContextProperties
offset?: number; offset?: number;
arrow?: boolean; arrow?: boolean;
class?: Class; class?: Class;
style?: Record<string, string | undefined | boolean | number> | string;
viewport?: HTMLElement;
} }
export interface PopperProperties extends ContextProperties export interface PopperProperties extends ContextProperties
{ {
content?: NodeChildren; content?: NodeChildren;
delay?: number; delay?: number;
viewport?: HTMLElement;
onShow?: (element: HTMLDivElement) => boolean | void; onShow?: (element: HTMLDivElement) => boolean | void;
onHide?: (element: HTMLDivElement) => boolean | void; onHide?: (element: HTMLDivElement) => boolean | void;
@ -36,7 +37,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
{ {
let shown = false, timeout: Timer; let shown = false, timeout: Timer;
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]); const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]);
const content = dom('div', { class: ['fixed hidden', properties?.class], attributes: { 'data-state': 'closed' } }, [...(properties?.content ?? []), arrow]); const content = dom('div', { class: ['fixed hidden', properties?.class], style: properties?.style, attributes: { 'data-state': 'closed' } }, [...(properties?.content ?? []), arrow]);
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport'; const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
function update() function update()
@ -151,7 +152,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
return container; return container;
} }
export function contextmenu(x: number, y: number, content: NodeChildren, properties?: ContextProperties): () => void export function contextmenu(x: number, y: number, content: NodeChildren, properties?: ContextProperties & { blur?: () => void })
{ {
const virtual = { const virtual = {
getBoundingClientRect() { getBoundingClientRect() {
@ -167,9 +168,10 @@ export function contextmenu(x: number, y: number, content: NodeChildren, propert
}; };
}, },
}; };
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]); const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]);
const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class] }, content); const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class], style: properties?.style }, content);
function update() function update()
{ {
@ -178,9 +180,10 @@ export function contextmenu(x: number, y: number, content: NodeChildren, propert
strategy: 'fixed', strategy: 'fixed',
middleware: [ middleware: [
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined, properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
FloatingUI.flip(), FloatingUI.shift({ rootBoundary: rect }),
properties?.offset ? FloatingUI.shift({ padding: properties?.offset }) : undefined, properties?.offset ? FloatingUI.shift({ padding: properties?.offset, rootBoundary: rect }) : undefined,
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined, properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
FloatingUI.hide({ rootBoundary: rect }),
] ]
}).then(({ x, y, placement, middlewareData }) => { }).then(({ x, y, placement, middlewareData }) => {
Object.assign(container.style, { Object.assign(container.style, {
@ -242,16 +245,27 @@ export function contextmenu(x: number, y: number, content: NodeChildren, propert
container.remove(); container.remove();
stop(); stop();
properties?.blur && properties.blur();
} }
return close; return { close, container, content };
}
export function tooltip(container: HTMLElement, txt: string, placement: FloatingUI.Placement, delay?: number): HTMLElement
{
return popper(container, {
arrow: true,
offset: 8,
delay: delay,
content: [ text(txt) ],
placement: placement,
class: "fixed hidden TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50"
});
} }
export function modal(content: NodeChildren, properties?: ModalProperties) export function fullblocker(content: NodeChildren, properties?: ModalProperties)
{ {
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (() => _modal.remove()) : undefined } }); const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (() => _modal.remove()) : undefined } });
const _closer = properties?.priority ? undefined : dom('span', { class: 'absolute top-4 right-4', text: '×', listeners: { click: () => _modal.remove() } }); const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, ...content]);
const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, dom('div', { class: 'max-h-[85vh] max-w-[450px] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 text-light-100 dark:text-dark-100 z-10 relative' }, content)])
teleport.appendChild(_modal); teleport.appendChild(_modal);
@ -259,6 +273,10 @@ export function modal(content: NodeChildren, properties?: ModalProperties)
close: () => _modal.remove(), close: () => _modal.remove(),
} }
} }
export function modal(content: NodeChildren, properties?: ModalProperties)
{
return fullblocker([ dom('div', { class: 'max-h-[85vh] max-w-[450px] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 text-light-100 dark:text-dark-100 z-10 relative' }, content) ], properties);
}
export function confirm(title: string): Promise<boolean> export function confirm(title: string): Promise<boolean>
{ {

View File

@ -7,7 +7,7 @@ export function unifySlug(slug: string | string[]): string
export function getID(length: number) export function getID(length: number)
{ {
for (var id = [], i = 0; i < length; i++) for (var id = [], i = 0; i < length; i++)
id.push((16 * Math.random() | 0).toString(16)); id.push((36 * Math.random() | 0).toString(36));
return id.join(""); return id.join("");
} }
export function group< export function group<

View File

@ -1,11 +1,11 @@
import { dom, icon, type NodeChildren, type Node, type NodeProperties, type Class, mergeClasses } from "#shared/dom.util"; import { dom, icon, type NodeChildren, type Node, type NodeProperties, type Class, mergeClasses, text, div } from "#shared/dom.util";
import { parseURL } from 'ufo'; import { parseURL } from 'ufo';
import render from "#shared/markdown.util"; import render from "#shared/markdown.util";
import { popper } from "#shared/floating.util"; import { contextmenu, popper } from "#shared/floating.util";
import { Canvas } from "#shared/canvas.util"; import { Canvas } from "#shared/canvas.util";
import { Content, iconByType, type LocalContent } from "#shared/content.util"; import { Content, iconByType, type LocalContent } from "#shared/content.util";
import type { RouteLocationAsRelativeTyped, RouteMapGeneric } from "vue-router"; import type { RouteLocationAsRelativeTyped, RouteMapGeneric } from "vue-router";
import { unifySlug } from "#shared/general.util"; import { clamp, unifySlug } from "#shared/general.util";
export type CustomProse = (properties: any, children: NodeChildren) => Node; export type CustomProse = (properties: any, children: NodeChildren) => Node;
export type Prose = { class: string } | { custom: CustomProse }; export type Prose = { class: string } | { custom: CustomProse };
@ -230,4 +230,194 @@ export function button(content: Node, onClick?: () => void, cls?: Class)
return dom('button', { class: [`text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none return dom('button', { class: [`text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50`, cls], listeners: { click: onClick } }, [ content ]); disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50`, cls], listeners: { click: onClick } }, [ content ]);
}
export function select<T extends NonNullable<any>>(options: Array<string | undefined> | Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement
{
const textFromValue = (value?: T): string => {
if(!value)
return '';
const found = options.find(e => (e as { value: string } | undefined)?.value === value || e === value);
if(!found)
return '';
return (found as { text: string } | undefined)?.text ?? found as string;
};
let close: Function | undefined;
let disabled = settings?.disabled ?? false;
const textValue = text(textFromValue(settings?.defaultValue));
const optionElements = options.map(e => {
if(e === undefined)
return;
return dom('div', { listeners: { click: () => {
let text, value;
if(typeof e === 'string')
{
text = value = e;
}
else
{
text = e.text;
value = e.value;
}
textValue.textContent = text;
settings?.change && settings?.change(value);
close && close();
} }, class: ['hover:bg-light-30 dark:hover:bg-dark-30 text-light-70 dark:text-dark-70 hover:text-light-100 dark:hover:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ text((e as { text: string } | undefined)?.text ?? e as string) ]);
});
const select = dom('div', { listeners: { click: () => {
if(disabled)
return;
const box = select.getBoundingClientRect();
close = contextmenu(box.x, box.y + box.height, optionElements.filter(e => !!e).length > 0 ? optionElements : [ div('text-light-60 dark:text-dark-60 italic text-center px-2 py-1', [ text('Aucune option') ]) ], { placement: "bottom-start", class: ['flex flex-col max-h-[320px] overflow-auto', settings?.class?.popup], style: { "min-width": `${box.width}px` } }).close;
} }, class: ['mx-4 inline-flex items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1 bg-light-20 dark:bg-dark-20 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] }, [ dom('span', {}, [ textValue ]), icon('radix-icons:caret-down') ]);
Object.defineProperty(select, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
select.toggleAttribute('data-disabled', disabled);
},
})
return select;
}
export function combobox<T extends NonNullable<any>>(options: Array<string | undefined> | Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement
{
const textFromValue = (value?: T): string => {
if(!value)
return '';
const found = options.find(e => (e as { value: string } | undefined)?.value === value || e === value);
if(!found)
return '';
return (found as { text: string } | undefined)?.text ?? found as string;
};
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
let selected = true;
const show = () => {
if(disabled || (context && context.container.parentElement))
return;
const box = container.getBoundingClientRect();
context = contextmenu(box.x, box.y + box.height, optionElements.filter(e => !!e).length > 0 ? optionElements.map(e => e?.dom) : [ div('text-light-60 dark:text-dark-60 italic text-center px-2 py-1', [ text('Aucune option') ]) ], { placement: "bottom-start", class: ['flex flex-col max-h-[320px] overflow-auto', settings?.class?.popup], style: { "min-width": `${box.width}px` }, blur: () => { if(!selected) container.classList.add('!border-light-red', 'dark:!border-dark-red') } });
if(!selected) container.classList.remove('!border-light-red', 'dark:!border-dark-red');
};
const hide = () => {
if(!context || !context.container.parentElement)
return;
context.close();
if(!selected) container.classList.add('!border-light-red', 'dark:!border-dark-red');
};
let disabled = settings?.disabled ?? false;
const optionElements = options.map((e, i) => {
if(e === undefined)
return;
return { item: e, dom: dom('div', { listeners: { click: () => {
let text, value;
if(typeof e === 'string')
{
text = value = e;
}
else
{
text = e.text;
value = e.value;
}
select.value = text;
settings?.change && settings?.change(value);
selected = true;
hide();
} }, class: ['hover:bg-light-30 dark:hover:bg-dark-30 text-light-70 dark:text-dark-70 hover:text-light-100 dark:hover:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ text((e as { text: string } | undefined)?.text ?? e as string) ]) };
});
const select = dom('input', { listeners: { focus: show, input: () => {
context && context?.container.replaceChildren(...optionElements.filter(e => {
if(e === undefined)
return false;
if(typeof e.item === 'string')
return (e.item as string).toLowerCase().includes(select.value.toLowerCase());
return e.item.text.toLowerCase().includes(select.value.toLowerCase());
}).map(e => e!.dom));
selected = false;
if(!context || !context.container.parentElement) container.classList.add('!border-light-red', 'dark:!border-dark-red')
} }, 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' });
select.value = textFromValue(settings?.defaultValue);
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') ]);
Object.defineProperty(container, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
container.toggleAttribute('data-disabled', disabled);
select.toggleAttribute('disabled', disabled);
},
})
return container;
}
export function input(type: 'text' | 'number' | 'email' | 'password' | 'tel', settings?: { defaultValue?: string, change?: (value: string) => void, input?: (value: string) => void, focus?: () => void, blur?: () => void, class?: Class, disabled?: boolean }): HTMLInputElement
{
const input = dom("input", { attributes: { disabled: settings?.disabled }, class: [`mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20`, settings?.class], listeners: {
input: () => settings?.input && settings.input(input.value),
change: () => settings?.change && settings.change(input.value),
focus: () => settings?.focus,
blur: () => settings?.blur,
}})
if(settings?.defaultValue !== undefined) input.value = settings.defaultValue;
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
{
let storedValue = settings?.defaultValue ?? 0;
const validateAndChange = (value: number) => {
if(isNaN(value))
field.value = '';
else
{
value = clamp(value, settings?.min ?? -Infinity, settings?.max ?? Infinity);
field.value = value.toString(10);
if(storedValue !== value)
{
storedValue = value;
return true;
}
}
return false;
}
const field = dom("input", { attributes: { disabled: settings?.disabled }, class: [`w-14 mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20`, settings?.class], listeners: {
input: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.input && settings.input(storedValue),
keydown: (e: KeyboardEvent) => {
switch(e.key)
{
case "ArrowUp":
validateAndChange(storedValue + (e.shiftKey ? 10 : 1)) && settings?.input && settings.input(storedValue);
break;
case "ArrowDown":
validateAndChange(storedValue - (e.shiftKey ? 10 : 1)) && settings?.input && settings.input(storedValue);
break;
case "PageUp":
settings?.max && validateAndChange(settings.max) && settings?.input && settings.input(storedValue);
break;
case "PageDown":
settings?.min && validateAndChange(settings.min) && settings?.input && settings.input(storedValue);
break;
default:
return;
}
},
change: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.change && settings.change(storedValue),
focus: () => settings?.focus && settings.focus(),
blur: () => settings?.blur && settings.blur(),
}});
if(settings?.defaultValue) field.value = storedValue.toString(10);
return field;
} }

63
types/character.d.ts vendored
View File

@ -40,10 +40,11 @@ export type CharacterValues = {
}; };
export type CharacterConfig = { export type CharacterConfig = {
peoples: RaceConfig[], peoples: RaceConfig[],
training: Record<MainStat, Record<TrainingLevel, TrainingOption[]>>; training: Record<MainStat, Record<TrainingLevel, string[]>>;
abilities: Record<Ability, AbilityConfig>; abilities: Record<Ability, AbilityConfig>;
spells: SpellConfig[]; spells: SpellConfig[];
aspects: AspectConfig[]; aspects: AspectConfig[];
features: Record<string, Feature>;
}; };
export type SpellConfig = { export type SpellConfig = {
id: string; id: string;
@ -64,7 +65,7 @@ export type AbilityConfig = {
export type RaceConfig = { export type RaceConfig = {
name: string; name: string;
description: string; description: string;
options: Record<Level, Feature[]>; options: Record<Level, string[]>;
}; };
export type AspectConfig = { export type AspectConfig = {
name: string; name: string;
@ -80,23 +81,22 @@ export type AspectConfig = {
}; };
export type FeatureEffect = { export type FeatureEffect = {
id: string;
category: "value"; category: "value";
operation: "add" | "set"; operation: "add" | "set";
property: string; property: string;
value: number | `modifier/${MainStat}`; value: number | `modifier/${MainStat}` | false;
} | {
category: "feature";
kind: "action" | "reaction" | "freeaction" | "passive";
text: string;
} | { } | {
id: string;
category: "list"; category: "list";
list: "spells"; list: "spells" | "action" | "reaction" | "freeaction" | "passive";
action: "add" | "remove"; action: "add" | "remove";
item: string; item: string;
extra?: any;
}; };
export type FeatureItem = FeatureEffect | { export type FeatureItem = FeatureEffect | {
category: "choice";
id: string; id: string;
category: "choice";
settings?: { //If undefined, amount is 1 by default settings?: { //If undefined, amount is 1 by default
amount: number; amount: number;
exclusive: boolean; //Disallow to pick the same option twice exclusive: boolean; //Disallow to pick the same option twice
@ -104,55 +104,26 @@ export type FeatureItem = FeatureEffect | {
options: Array<FeatureEffect & { text: string }>; options: Array<FeatureEffect & { text: string }>;
} }
export type Feature = { export type Feature = {
name?: string; id: string;
description: string; description: string;
effect: FeatureItem[]; effect: FeatureItem[];
}; };
export type TrainingOption = {
description: Array<{
text: string;
disposable?: boolean;
replaced?: boolean;
category?: Category;
}>;
//Automatically calculated by compiler
mana?: number;
health?: number;
speed?: false | number;
initiative?: number;
mastery?: keyof CompiledCharacter["mastery"];
spellrank?: SpellType;
defense?: Array<keyof CompiledCharacter["defense"]>;
resistance?: Record<MainStat, number>;
bonus?: Record<string, number>;
spell?: string;
//Used during character creation, not used by compiler
modifier?: number;
ability?: number;
spec?: number;
spellslot?: number | MainStat;
arts?: number | MainStat;
features?: FeatureItem[]; //TODO
};
export type CompiledCharacter = { export type CompiledCharacter = {
id: number; id: number;
owner?: number; owner?: number;
username?: string; username?: string;
name: string; name: string;
health: number; health: number; //Max
mana: number; mana: number; //Max
race: number; race: number;
spellslots: number; spellslots: number; //Max
artslots: number; artslots: number; //Max
spellranks: Record<SpellType, 0 | 1 | 2 | 3>; spellranks: Record<SpellType, 0 | 1 | 2 | 3>;
aspect: string; aspect: string; //ID
speed: number | false; speed: number | false;
capacity: number | false;
initiative: number; initiative: number;
spells: string[];
values: CharacterValues, values: CharacterValues,
@ -183,7 +154,7 @@ export type CompiledCharacter = {
modifier: Record<MainStat, number>; modifier: Record<MainStat, number>;
abilities: Partial<Record<Ability, number>>; abilities: Partial<Record<Ability, number>>;
level: number; level: number;
features: { [K in Extract<FeatureEffect, { category: "feature" }>["kind"]]?: string[] }; lists: { [K in Extract<FeatureEffect, { category: "list" }>["list"]]?: string[] };
notes: string; notes: string;
}; };