15 Commits

Author SHA1 Message Date
8e551159fc Fix UI for mobile render 2026-07-01 09:37:37 +02:00
8cbc25a601 Migration from tailwindcss v3 to v4. Deletion of nuxt/tailwindcss. 2026-06-16 15:54:38 +02:00
a5317d6156 Fixes and responsive character sheet. 2026-06-16 11:14:46 +02:00
bc1839c5e3 Rollback CharacterEditor to the previous version 2026-06-10 13:29:33 +02:00
f9e0473b2a Item Improvements added to the homebrew manager. 2026-06-08 16:53:54 +02:00
Clément Pons
3bafc14255 Fix dynamic character sheet loading. 2026-03-09 17:27:18 +01:00
Clément Pons
974989abd3 Fix nuxt build pages not rendering 2026-02-16 11:54:29 +01:00
Clément Pons
9face0ac3b Try to add character editor inside the character sheet 2026-02-13 17:34:35 +01:00
Clément Pons
898d95793a Dice roll parsing and stringifying. 2026-02-04 17:51:30 +01:00
Clément Pons
8335871883 Add action variants and cursed items. 2026-02-03 17:39:21 +01:00
3081c05b55 Implement Aspect tab and HP/Mana editor 2026-01-28 21:38:10 +01:00
Clément Pons
a412116b9c Rename RedrawableHTML, remove File API rate limite and fix pull job transaction. 2026-01-27 17:13:40 +01:00
e9a892076d Add logic tree computation and item enchantment. 2026-01-26 00:05:05 +01:00
Clément Pons
777443471c Change shared files naming. Rework tree structure and item management rendering. 2026-01-20 18:14:07 +01:00
Clément Pons
1a71637ebb Change shared files naming. Rework tree structure and item management rendering. 2026-01-20 18:14:05 +01:00
94 changed files with 4136 additions and 2405 deletions

View File

@@ -3,7 +3,7 @@
<NuxtRouteAnnouncer/>
<NuxtLoadingIndicator :throttle="50"/>
<NuxtLayout>
<div class="xl:px-12 xl:py-8 lg:px-8 lg:py-6 px-6 py-3 flex flex-1 justify-center overflow-auto max-h-full max-w-full relative" id="mainContainer">
<div class="xl:px-12 xl:py-8 lg:px-8 lg:py-6 md:px-6 py-3 px-3 flex flex-1 justify-center overflow-auto max-h-full max-w-full relative @container/main box-border" id="mainContainer">
<NuxtPage />
</div>
</NuxtLayout>
@@ -11,9 +11,9 @@
</template>
<script setup lang="ts">
import { Content } from '#shared/content.util';
import * as Floating from '#shared/floating.util';
import { Toaster } from '#shared/components.util';
import { Content } from '#shared/content';
import * as Floating from '#shared/floating';
import { Toaster } from '#shared/components';
import { init } from '#shared/i18n';
onBeforeMount(() => {
@@ -36,6 +36,7 @@ onBeforeMount(() => {
</script>
<style>
@reference "./assets/css/main.css";
iconify-icon
{
display: inline-block;
@@ -46,71 +47,76 @@ iconify-icon
.ToastRoot[data-type='error'] {
@apply border-light-red;
@apply dark:border-dark-red;
@apply bg-light-red;
@apply dark:bg-dark-red;
@apply !bg-opacity-50;
@apply bg-light-red/50;
@apply dark:bg-dark-red/50;
}
.ToastRoot[data-type='success'] {
@apply border-light-green;
@apply dark:border-dark-green;
@apply bg-light-green;
@apply dark:bg-dark-green;
@apply !bg-opacity-50;
@apply bg-light-green/50;
@apply dark:bg-dark-green/50;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-thumb {
@apply bg-light-40;
@apply dark:bg-dark-40;
@apply bg-light-40!;
@apply dark:bg-dark-40!;
@apply rounded-md;
@apply border-2;
@apply border-solid;
@apply border-transparent;
@apply bg-clip-padding;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-light-50;
@apply dark:bg-dark-50;
}
.no-scroll::-webkit-scrollbar {
width: 0px;
height: 0px;
display: none;
}
.no-scroll::-webkit-scrollbar-thumb {
@apply bg-transparent;
display: none;
}
.callout[data-type="abstract"],
.callout[data-type="summary"],
.callout[data-type="tldr"]
{
@apply bg-light-cyan;
@apply dark:bg-dark-cyan;
@apply bg-light-cyan/25;
@apply dark:bg-dark-cyan/25;
@apply text-light-cyan;
@apply dark:text-dark-cyan;
}
.callout[data-type="info"]
{
@apply bg-light-blue;
@apply dark:bg-dark-blue;
@apply bg-light-blue/25;
@apply dark:bg-dark-blue/25;
@apply text-light-blue;
@apply dark:text-dark-blue;
}
.callout[data-type="todo"]
{
@apply bg-light-blue;
@apply dark:bg-dark-blue;
@apply bg-light-blue/25;
@apply dark:bg-dark-blue/25;
@apply text-light-blue;
@apply dark:text-dark-blue;
}
.callout[data-type="important"]
{
@apply bg-light-cyan;
@apply dark:bg-dark-cyan;
@apply bg-light-cyan/25;
@apply dark:bg-dark-cyan/25;
@apply text-light-cyan;
@apply dark:text-dark-cyan;
}
.callout[data-type="tip"],
.callout[data-type="hint"]
{
@apply bg-light-cyan;
@apply dark:bg-dark-cyan;
@apply bg-light-cyan/25;
@apply dark:bg-dark-cyan/25;
@apply text-light-cyan;
@apply dark:text-dark-cyan;
}
@@ -118,8 +124,8 @@ iconify-icon
.callout[data-type="check"],
.callout[data-type="done"]
{
@apply bg-light-green;
@apply dark:bg-dark-green;
@apply bg-light-green/25;
@apply dark:bg-dark-green/25;
@apply text-light-green;
@apply dark:text-dark-green;
}
@@ -127,8 +133,8 @@ iconify-icon
.callout[data-type="help"],
.callout[data-type="faq"]
{
@apply bg-light-orange;
@apply dark:bg-dark-orange;
@apply bg-light-orange/25;
@apply dark:bg-dark-orange/25;
@apply text-light-orange;
@apply dark:text-dark-orange;
}
@@ -136,8 +142,8 @@ iconify-icon
.callout[data-type="caution"],
.callout[data-type="attention"]
{
@apply bg-light-orange;
@apply dark:bg-dark-orange;
@apply bg-light-orange/25;
@apply dark:bg-dark-orange/25;
@apply text-light-orange;
@apply dark:text-dark-orange;
}
@@ -145,30 +151,30 @@ iconify-icon
.callout[data-type="fail"],
.callout[data-type="missing"]
{
@apply bg-light-red;
@apply dark:bg-dark-red;
@apply bg-light-red/25;
@apply dark:bg-dark-red/25;
@apply text-light-red;
@apply dark:text-dark-red;
}
.callout[data-type="danger"],
.callout[data-type="error"]
{
@apply bg-light-red;
@apply dark:bg-dark-red;
@apply bg-light-red/25;
@apply dark:bg-dark-red/25;
@apply text-light-red;
@apply dark:text-dark-red;
}
.callout[data-type="bug"]
{
@apply bg-light-red;
@apply dark:bg-dark-red;
@apply bg-light-red/25;
@apply dark:bg-dark-red/25;
@apply text-light-red;
@apply dark:text-dark-red;
}
.callout[data-type="example"]
{
@apply bg-light-purple;
@apply dark:bg-dark-purple;
@apply bg-light-purple/25;
@apply dark:bg-dark-purple/25;
@apply text-light-purple;
@apply dark:text-dark-purple;
}
@@ -186,7 +192,7 @@ iconify-icon
}
.cm-focused
{
@apply outline-none;
@apply outline-hidden;
}
.cm-editor .cm-content
{
@@ -200,10 +206,10 @@ iconify-icon
.cm-tooltip-autocomplete {
@apply max-w-[400px];
@apply !bg-light-20;
@apply dark:!bg-dark-20;
@apply !border-light-40;
@apply dark:!border-dark-40;
@apply bg-light-20;
@apply dark:bg-dark-20;
@apply border-light-40;
@apply dark:border-dark-40;
}
/* .cm-tooltip-autocomplete > ul {
@@ -213,18 +219,18 @@ iconify-icon
.cm-tooltip-autocomplete > ul > li {
@apply flex;
@apply flex-col;
@apply !py-1;
@apply py-1!;
@apply hover:bg-light-30;
@apply dark:hover:bg-dark-30;
@apply hover:dark:bg-dark-30;
}
.cm-tooltip-autocomplete > ul > li[aria-selected] {
@apply !bg-light-35;
@apply dark:!bg-dark-35;
@apply bg-light-35!;
@apply dark:bg-dark-35!;
}
.cm-completionIcon {
@apply !hidden;
@apply hidden!;
}
.cm-completionLabel {
@@ -238,7 +244,7 @@ iconify-icon
.cm-completionMatchedText {
@apply font-bold;
@apply !no-underline;
@apply no-underline!;
}
.cm-completionDetail {

92
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,92 @@
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@source "../../../shared";
@theme {
--color-transparent: transparent;
--color-current: currentColor;
--color-light-red: #e93147;
--color-light-orange: #FF9800;
--color-light-yellow: #FFEB3B;
--color-light-green: #388E3C;
--color-light-indigo: #7986CB;
--color-light-cyan: #00bfbc;
--color-light-lime: #8BC34A;
--color-light-blue: #086ddd;
--color-light-purple: #AB47BC;
--color-light-pink: #d53984;
--color-light-0: #ffffff;
--color-light-5: #fcfcfc;
--color-light-10: #fafafa;
--color-light-20: #f7f7f7;
--color-light-25: #e4e4e4;
--color-light-30: #dfdfdf;
--color-light-35: #d2d2d2;
--color-light-40: #bdbdbd;
--color-light-50: #ababab;
--color-light-60: #707070;
--color-light-70: #5c5c5c;
--color-light-100: #202020;
--color-dark-red: #fb464c;
--color-dark-redBack: #5A292B;
--color-dark-orange: #e9973f;
--color-dark-yellow: #e0de71;
--color-dark-green: #44cf6e;
--color-dark-greenBack: #284E34;
--color-dark-cyan: #53dfdd;
--color-dark-blue: #027aff;
--color-dark-purple: #a882ff;
--color-dark-pink: #fa99cd;
--color-dark-0: #1e1e1e;
--color-dark-5: #212121;
--color-dark-10: #242424;
--color-dark-20: #262626;
--color-dark-25: #2a2a2a;
--color-dark-30: #363636;
--color-dark-35: #3f3f3f;
--color-dark-40: #555555;
--color-dark-50: #666666;
--color-dark-60: #999999;
--color-dark-70: #b3b3b3;
--color-dark-100: #dadada;
--color-accent-purple: #43A047;
--color-accent-blue: #26C6DA;
--shadow-raw: 0 0 0 2px var(--tw-shadow-color);
--animate-slideDownAndFade: slideDownAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-slideLeftAndFade: slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-slideUpAndFade: slideUpAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-slideRightAndFade: slideRightAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-contentShow: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideDownAndFade {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideLeftAndFade {
from { opacity: 0; transform: translateX(2px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slideUpAndFade {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideRightAndFade {
from { opacity: 0; transform: translateX(-2px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes contentShow {
from { opacity: 0; transform: translate(-50%, -48%) scale(0.96); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import render, { type MDProperties } from '#shared/markdown.util'
import render, { type MDProperties } from '~~/shared/markdown'
const { content, filter, properties } = defineProps<{
content?: string,
filter?: string,

View File

@@ -1,6 +1,6 @@
<template>
<button :disabled="disabled" 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
<button :disabled="disabled" class="text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 hover:dark:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-hidden
border border-light-25 dark:border-dark-25 hover:border-light-30 hover:dark:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 focus:dark:shadow-dark-40
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50"
:class="{'p-1': loading || icon, 'h-[35px] px-[15px]': !loading && !icon}" @click="!loading && emit('click')">
<Loading v-if="loading" />

View File

@@ -3,9 +3,9 @@
<span class="pb-1 md:p-0">{{ label }}</span>
<ComboboxRoot v-model:model-value="model" v-model:open="open" :multiple="multiple">
<ComboboxAnchor :disabled="disabled" class="mx-4 inline-flex min-w-[150px] 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 outline-none data-[placeholder]:font-normal
data-[placeholder]:text-light-50 dark:data-[placeholder]:text-dark-50 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
hover:border-light-50 dark:hover:border-dark-50">
bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-hidden data-[placeholder]:font-normal
data-[placeholder]:text-light-50 dark:data-[placeholder]:text-dark-50 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 focus:dark:shadow-dark-40
hover:border-light-50 hover:dark:border-dark-50">
<ComboboxTrigger class="flex flex-1 justify-between !cursor-pointer">
<span v-if="!multiple">{{ model !== undefined ? options.find(e => e[1] === model)![0] : "" }}</span>
<span class="flex gap-2" v-else><span v-if="model !== undefined">{{ options.find(e => e[1] === (model as T[])[0]) !== undefined ? options.find(e => e[1] === (model as T[])[0])![0] : undefined }}</span><span v-if="model !== undefined && (model as T[]).length > 1">{{((model as T[]).length > 1 ? `+${(model as T[]).length - 1}` : "") }}</span></span>
@@ -16,7 +16,7 @@
<ComboboxPortal :disabled="disabled">
<ComboboxContent :position="position" align="start" class="min-w-[150px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-50">
<ComboboxViewport>
<ComboboxItem v-for="[label, value] of options" :value="value" :disabled="disabled" class="text-base py-2 leading-none text-light-60 dark:text-dark-60 flex items-center px-6 relative Combobox-none data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-light-30 dark:data-[highlighted]:bg-dark-30 data-[highlighted]:text-light-100 dark:data-[highlighted]:text-dark-100">
<ComboboxItem v-for="[label, value] of options" :value="value" :disabled="disabled" class="text-base py-2 leading-none text-light-60 dark:text-dark-60 flex items-center px-6 relative Combobox-none data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50 data-[disabled]:pointer-events-none data-[highlighted]:outline-hidden data-[highlighted]:bg-light-30 dark:data-[highlighted]:bg-dark-30 data-[highlighted]:text-light-100 dark:data-[highlighted]:text-dark-100">
<span class="">{{ label }}</span>
<ComboboxItemIndicator class="absolute left-1 w-4 inline-flex items-center justify-center">
<Icon icon="radix-icons:check" />

View File

@@ -7,7 +7,7 @@
<DialogTitle v-if="!!title" class="text-3xl font-light relative -top-2">{{ title }}</DialogTitle>
<DialogDescription v-if="!!description" class="text-base pb-2">{{ description }}</DialogDescription>
<slot />
<DialogClose v-if="iconClose" class="text-light-100 dark:text-dark-100 absolute top-2 right-2 inline-flex h-6 w-6 appearance-none items-center justify-center outline-none text-xl" aria-label="Close">
<DialogClose v-if="iconClose" class="text-light-100 dark:text-dark-100 absolute top-2 right-2 inline-flex h-6 w-6 appearance-none items-center justify-center outline-hidden text-xl" aria-label="Close">
<span aria-hidden>×</span>
</DialogClose>
</DialogContent>

View File

@@ -1,7 +1,7 @@
<template>
<template v-for="(item, idx) of options">
<template v-if="item.type === 'item'">
<DropdownMenuItem :disabled="item.disabled" :textValue="item.label" @select="item.select" :class="{'!pe-1': item.kbd}" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<DropdownMenuItem :disabled="item.disabled" :textValue="item.label" @select="item.select" :class="{'!pe-1': item.kbd}" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-hidden data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<Icon v-if="item.icon" :icon="item.icon" class="absolute left-1.5" />
<div class="flex flex-1 justify-between">
<span>{{ item.label }}</span>
@@ -11,7 +11,7 @@
</template>
<template v-else-if="item.type === 'checkbox'">
<DropdownMenuCheckboxItem :disabled="item.disabled" :textValue="item.label" v-model:checked="item.checked" @update:checked="item.select" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<DropdownMenuCheckboxItem :disabled="item.disabled" :textValue="item.label" v-model:checked="item.checked" @update:checked="item.select" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative pe-4 select-none outline-hidden data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<span class="w-6 flex items-center justify-center">
<DropdownMenuItemIndicator>
<Icon icon="radix-icons:check" />
@@ -41,13 +41,13 @@
<template v-if="item.type === 'submenu'">
<DropdownMenuSub>
<DropdownMenuSubTrigger class="group cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<DropdownMenuSubTrigger class="group cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 select-none outline-hidden data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<Icon v-if="item.icon" :icon="item.icon" />
<span>{{ item.label }}</span>
<Icon icon="radix-icons:chevron-right" class="absolute right-1" />
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
<DropdownMenuSubContent class="z-50 outline-hidden bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
<DropdownContentRender :options="item.items" />
</DropdownMenuSubContent>
</DropdownMenuPortal>

View File

@@ -3,7 +3,7 @@
<DropdownMenuTrigger :disabled="disabled"><slot /></DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent :align="align" :side="side" class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
<DropdownMenuContent :align="align" :side="side" class="z-50 outline-hidden bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
<DropdownContentRender :options="options" />
<DropdownMenuArrow class="fill-light-35 dark:fill-dark-35" />

View File

@@ -1,6 +1,6 @@
<template>
<HoverCardRoot :open-delay="delay" @update:open="(...args) => emits('open', ...args)">
<HoverCardTrigger class="inline-block cursor-help outline-none">
<HoverCardTrigger class="inline-block cursor-help outline-hidden">
<slot></slot>
</HoverCardTrigger>
<HoverCardPortal v-if="!disabled">

View File

@@ -1,3 +1,3 @@
<template>
<span class="rounded bg-light-35 dark:bg-dark-35 font-mono text-sm px-1 py-0 select-none" style="box-shadow: black 0 2px 0 1px;"><slot /></span>
<span class="rounded-sm bg-light-35 dark:bg-dark-35 font-mono text-sm px-1 py-0 select-none" style="box-shadow: black 0 2px 0 1px;"><slot /></span>
</template>

View File

@@ -1,9 +1,9 @@
<template>
<Label class="my-2 flex">{{ label }}
<NumberFieldRoot :min="min" :max="max" v-model="model" :disabled="disabled" :step="step" class="ms-4 flex justify-center border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 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">
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 hover:border-light-50 hover:dark:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
<NumberFieldDecrement class="data-[disabled]:opacity-50 px-1"><Icon icon="radix-icons:minus" :inline="true" class="w-6 text-light-100 dark:text-dark-100 opacity-100" /></NumberFieldDecrement>
<NumberFieldInput class="text-sm tabular-nums w-20 appearance-none bg-transparent px-2 py-1 outline-none caret-light-50 dark:caret-dark-50" />
<NumberFieldInput class="text-sm tabular-nums w-20 appearance-none bg-transparent px-2 py-1 outline-hidden caret-light-50 dark:caret-dark-50" />
<NumberFieldIncrement class="data-[disabled]:opacity-50 px-1"><Icon icon="radix-icons:plus" :inline="true" class="w-6 text-light-100 dark:text-dark-100 opacity-100" /></NumberFieldIncrement>
</NumberFieldRoot>
</Label>

View File

@@ -1,9 +1,9 @@
<template>
<Label class="my-2">{{ label }}
<PinInputRoot :disabled="disabled" :default-value="model?.split('')" @update:model-value="(v) => model = v.join('')" @complete="() => emit('complete')" class="flex gap-2 items-center mt-1">
<PinInputInput :type="hidden ? 'password' : undefined" v-for="(id, index) in amount" :key="id" :index="index" class="border border-light-35 dark:border-dark-35 w-10 h-10 outline-none
<PinInputInput :type="hidden ? 'password' : undefined" v-for="(id, index) in amount" :key="id" :index="index" class="border border-light-35 dark:border-dark-35 w-10 h-10 outline-hidden
bg-light-20 dark:bg-dark-20 text-center text-light-100 dark:text-dark-100 placeholder:text-light-60 dark:placeholder:text-dark-60 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 caret-light-50 dark:caret-dark-50" />
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 focus:dark:shadow-dark-40 caret-light-50 dark:caret-dark-50" />
</PinInputRoot>
</Label>
</template>

View File

@@ -3,8 +3,8 @@
<Label v-for="option in options" class="flex items-center gap-2">
<RadioGroupItem :disabled="(option as RadioOption).disabled ?? false"
:value="(option as RadioOption).value ?? option"
class="border border-light-60 dark:border-dark-35 bg-light-20 dark:bg-dark-25 w-5 h-5 outline-none cursor-default hover:border-light-70 dark:hover:border-dark-40
focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 data-[disabled]:bg-light-10 dark:data-[disabled]:bg-dark-10 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20">
class="border border-light-60 dark:border-dark-35 bg-light-20 dark:bg-dark-25 w-5 h-5 outline-hidden cursor-default hover:border-light-70 hover:dark:border-dark-40
focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 focus:dark:shadow-dark-40 data-[disabled]:bg-light-10 dark:data-[disabled]:bg-dark-10 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20">
<RadioGroupIndicator>
<Icon icon="radix-icons:check" class="relative w-5 h-5 -top-px -left-px" />
</RadioGroupIndicator>

View File

@@ -3,9 +3,9 @@
<span class="pb-1 md:p-0">{{ label }}</span>
<SelectRoot v-model="model" :default-value="defaultValue">
<SelectTrigger :disabled="disabled" class="mx-4 inline-flex min-w-[160px] 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 outline-none data-[placeholder]:font-normal
data-[placeholder]:text-light-50 dark:data-[placeholder]:text-dark-50 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
hover:border-light-50 dark:hover:border-dark-50">
bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-hidden data-[placeholder]:font-normal
data-[placeholder]:text-light-50 dark:data-[placeholder]:text-dark-50 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 focus:dark:shadow-dark-40
hover:border-light-50 hover:dark:border-dark-50">
<SelectValue :placeholder="placeholder" />
<Icon icon="radix-icons:caret-down" class="h-4 w-4" />
</SelectTrigger>

View File

@@ -1,5 +1,5 @@
<template>
<SelectItem :value="value" :disabled="disabled" class="text-base py-2 leading-none text-light-60 dark:text-dark-60 flex items-center px-6 relative select-none data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-light-30 dark:data-[highlighted]:bg-dark-30 data-[highlighted]:text-light-100 dark:data-[highlighted]:text-dark-100">
<SelectItem :value="value" :disabled="disabled" class="text-base py-2 leading-none text-light-60 dark:text-dark-60 flex items-center px-6 relative select-none data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50 data-[disabled]:pointer-events-none data-[highlighted]:outline-hidden data-[highlighted]:bg-light-30 dark:data-[highlighted]:bg-dark-30 data-[highlighted]:text-light-100 dark:data-[highlighted]:text-dark-100">
<SelectItemText class="">{{ label }}</SelectItemText>
<SelectItemIndicator class="absolute left-1 w-4 inline-flex items-center justify-center">
<Icon icon="radix-icons:check" />

View File

@@ -7,8 +7,8 @@
<SliderRange class="absolute bg-light-40 dark:bg-dark-40 h-full data-[disabled]:bg-light-30 dark:data-[disabled]:bg-dark-30" />
</SliderTrack>
<SliderThumb
class="block w-5 h-5 bg-light-50 dark:bg-dark-50 outline-none focus:shadow-raw transition-[box-shadow] focus:shadow-light-60 dark:focus:shadow-dark-60 border border-light-50 dark:border-dark-50
hover:border-light-60 dark:hover:border-dark-60 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20" />
class="block w-5 h-5 bg-light-50 dark:bg-dark-50 outline-hidden focus:shadow-raw transition-[box-shadow] focus:shadow-light-60 focus:dark:shadow-dark-60 border border-light-50 dark:border-dark-50
hover:border-light-60 hover:dark:border-dark-60 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20" />
</SliderRoot>
</Label>
</template>

View File

@@ -2,8 +2,8 @@
<Label class="flex justify-center items-center my-2">
<span class="md:text-base text-sm">{{ label }}</span>
<SwitchRoot v-model:checked="model" :disabled="disabled" :default-checked="defaultValue"
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
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-hidden
data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 hover:dark:border-dark-50 focus:shadow-raw focus:shadow-light-40 focus:dark: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">
<SwitchThumb
class="block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 data-[state=checked]:translate-x-[26px]

View File

@@ -1,13 +1,13 @@
<template>
<TagsInputRoot v-model="model" addOnPaste class="flex gap-2 items-center border p-2 w-full flex-wrap border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10" >
<TagsInputItem v-for="item in model" :key="item" :value="item" class="text-light-100 dark:text-dark-100 flex items-center justify-center gap-2 bg-light-20 dark:bg-dark-20 hover:bg-light-35 dark:hover:bg-dark-35 p-1 border border-light-35 dark:border-dark-35">
<TagsInputItem v-for="item in model" :key="item" :value="item" class="text-light-100 dark:text-dark-100 flex items-center justify-center gap-2 bg-light-20 dark:bg-dark-20 hover:bg-light-35 hover:dark:bg-dark-35 p-1 border border-light-35 dark:border-dark-35">
<TagsInputItemText class="text-sm pl-1" />
<TagsInputItemDelete asChild>
<Icon icon="radix-icons:cross-2" class="w-4 h-4 cursor-pointer" />
</TagsInputItemDelete>
</TagsInputItem>
<TagsInputInput :placeholder="placeholder" class="text-sm focus:outline-none flex-1 rounded text-green9 bg-transparent placeholder:text-mauve9 px-1" />
<TagsInputInput :placeholder="placeholder" class="text-sm focus:outline-hidden flex-1 rounded-sm text-green9 bg-transparent placeholder:text-mauve9 px-1" />
</TagsInputRoot>
</template>

View File

@@ -3,8 +3,8 @@
<span class="pb-1 md:p-0">{{ label }}</span>
<input :placeholder="placeholder" :disabled="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"
bg-light-20 dark:bg-dark-20 appearance-none outline-hidden px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 focus:dark:shadow-dark-40
border border-light-35 dark:border-dark-35 hover:border-light-50 hover:dark: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"
:type="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs" @change="(e) => emits('change', e)" @input="(e) => emits('input', e)">
</Label>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 text-sm" :items="model" :get-key="getKey" :defaultExpanded="flatten(model)">
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level / 2 - 0.5}em` }" v-bind="item.bind" class="flex items-center ps-2 outline-none relative cursor-pointer">
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level / 2 - 0.5}em` }" v-bind="item.bind" class="flex items-center ps-2 outline-hidden relative cursor-pointer">
<slot :isExpanded="isExpanded" :item="item" />
</TreeItem>
</TreeRoot>

View File

@@ -9,10 +9,10 @@
<div class="text-3xl">Une erreur est survenue.</div>
</div>
<pre class="text-center text-wrap">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
<button class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
<button class="inline-flex justify-center items-center outline-hidden leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 p-2" @click="handleError">Revenir en lieu sûr</button>
hover:bg-light-25 hover:dark:bg-dark-25 hover:border-light-50 hover:dark:border-dark-50
focus:bg-light-30 focus:dark:bg-dark-30 focus:border-light-50 focus:dark:border-dark-50 focus:shadow-raw focus:shadow-light-50 focus:dark:shadow-dark-50 p-2" @click="handleError">Revenir en lieu sûr</button>
</div>
</template>

View File

@@ -1,50 +1,61 @@
<template>
<div class="flex flex-row w-full max-w-full h-full max-h-full" style="--sidebar-width: 300px">
<div class="bg-light-0 dark:bg-dark-0 w-[var(--sidebar-width)] border-r border-light-30 dark:border-dark-30 flex flex-col gap-2">
<NuxtLink class="flex flex-row items-center justify-center group gap-2 my-2" aria-label="Accueil" :to="{ name: 'index', force: true }">
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
<Avatar src="/logo.light.svg" class="block dark:hidden" />
<span class="text-xl font-semibold group-hover:text-light-70 dark:group-hover:text-dark-70">d[any]</span>
</NuxtLink>
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="treeParent"></div>
<div class="flex flex-col my-4 items-center justify-center gap-1 text-xs text-light-60 dark:text-dark-60">
<NuxtLink class="hover:underline" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
<NuxtLink class="hover:underline" :to="{ name: 'usage' }">Conditions d'utilisations</NuxtLink>
Copyright Peaceultime - 2025
</div>
</div>
<div class="flex flex-col flex-1 h-full w-[calc(100vw-var(--sidebar-width))]">
<div class="flex flex-row border-b border-light-30 dark:border-dark-30 justify-between px-8">
<div class="flex flex-row gap-16 items-center">
<NavigationMenuRoot class="relative">
<NavigationMenuList class="flex items-center gap-8 max-md:hidden">
<NavigationMenuItem>
<NavigationMenuTrigger>
<NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Personnages</span><Icon icon="radix-icons:caret-down" /></NuxtLink>
</NavigationMenuTrigger>
<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="hover:bg-light-30 dark:hover:bg-dark-30 px-4 py-2 select-none" active-class="!text-accent-blue"><span class="flex-1 truncate">Personnages publics</span></NuxtLink>
<NuxtLink :href="{ name: 'character-id-edit', params: { id: 'new' } }" class="hover:bg-light-30 dark:hover:bg-dark-30 px-4 py-2 select-none" active-class="!text-accent-blue"><span class="flex-1 truncate">Nouveau personnage</span></NuxtLink>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
<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] flex justify-center overflow-hidden sm:w-[var(--radix-navigation-menu-viewport-width)]" />
</div>
</NavigationMenuRoot>
<NuxtLink :href="{ name: 'campaign' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Campagnes</span></NuxtLink>
<div class="flex flex-row w-full h-full group/sidebar" :data-active="open || undefined">
<div v-if="open" class="fixed inset-0 bg-black/30 z-[5] xl:hidden" @click="open = false" />
<div class="bg-light-0 dark:bg-dark-0 z-10 transition-[width] w-0 group-data-[active]/sidebar:w-full group-data-[active]/sidebar:md:w-64 overflow-hidden border-r border-light-30 dark:border-dark-30 flex flex-col gap-2 absolute xl:fixed top-0 bottom-0 left-0" @click.capture="onSidebarClick">
<span class="w-full h-14"></span>
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="treeParent"></div>
<div class="flex flex-col my-4 items-center justify-center gap-1 text-xs text-light-60 dark:text-dark-60">
<NuxtLink class="hover:underline" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
<NuxtLink class="hover:underline" :to="{ name: 'usage' }">Conditions d'utilisations</NuxtLink>
Copyright Peaceultime - 2025
</div>
</div>
<div class="flex flex-col flex-1 h-full w-full">
<div class="flex flex-row w-full border-b border-light-30 dark:border-dark-30">
<div class="z-40 flex flex-row gap-4 items-center justify-between px-4 w-64">
<div class="inline-flex justify-center items-center outline-hidden leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 hover:dark:bg-dark-25 hover:border-light-50 hover:dark:border-dark-50
focus:bg-light-30 focus:dark:bg-dark-30 focus:border-light-50 focus:dark:border-dark-50 focus:shadow-raw focus:shadow-light-50 focus:dark:shadow-dark-50 p-1" @click="() => open = !open"><Icon :icon="open ? 'radix-icons:pin-left' : 'radix-icons:pin-right'" height="12" width="12"/></div>
<NuxtLink class="flex flex-row items-center justify-center group gap-2 my-2" aria-label="Accueil" :to="{ name: 'index', force: true }">
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
<Avatar src="/logo.light.svg" class="block dark:hidden" />
<span class="hidden md:inline text-xl font-semibold group-hover:text-light-70 dark:group-hover:text-dark-70">d[any]</span>
</NuxtLink>
<div></div>
</div>
<div class="flex flex-row gap-16 items-center">
<template v-if="!loggedIn">
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">Se connecter</NuxtLink>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70 max-md:hidden" :to="{ name: 'user-register' }">Créer un compte</NuxtLink>
</template>
<template v-else>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
</template>
<div class="flex flex-1 flex-row justify-between px-8">
<div class="flex flex-row gap-16 items-center">
<NavigationMenuRoot class="relative">
<NavigationMenuList class="flex items-center gap-8 max-md:hidden">
<NavigationMenuItem>
<NavigationMenuTrigger>
<NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="text-accent-blue"><span class="px-3 flex-1 truncate">Personnages</span><Icon icon="radix-icons:caret-down" /></NuxtLink>
</NavigationMenuTrigger>
<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="hover:bg-light-30 hover:dark:bg-dark-30 px-4 py-2 select-none" active-class="text-accent-blue"><span class="flex-1 truncate">Personnages publics</span></NuxtLink>
<NuxtLink :href="{ name: 'character-id-edit', params: { id: 'new' } }" class="hover:bg-light-30 hover:dark:bg-dark-30 px-4 py-2 select-none" active-class="text-accent-blue"><span class="flex-1 truncate">Nouveau personnage</span></NuxtLink>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
<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] flex justify-center overflow-hidden sm:w-[var(--radix-navigation-menu-viewport-width)]" />
</div>
</NavigationMenuRoot>
<NuxtLink :href="{ name: 'campaign' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="text-accent-blue"><span class="px-3 flex-1 truncate">Campagnes</span></NuxtLink>
</div>
<div class="flex flex-row gap-16 items-center">
<template v-if="!loggedIn">
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 hover:dark:text-dark-70" :to="{ name: 'user-login' }">Se connecter</NuxtLink>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 hover:dark:text-dark-70 max-md:hidden" :to="{ name: 'user-register' }">Créer un compte</NuxtLink>
</template>
<template v-else>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 hover:dark:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
</template>
</div>
</div>
</div>
<slot></slot>
<div class="flex flex-1 z-0 overflow-auto group-data-[active]/sidebar:xl:ms-64 transition-[margin]"><slot></slot></div>
</div>
</div>
</template>
@@ -52,20 +63,27 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { TreeDOM } from '#shared/tree';
import { Content, iconByType } from '#shared/content.util';
import { dom, icon } from '#shared/dom.util';
import { unifySlug } from '#shared/general.util';
import { tooltip } from '#shared/floating.util';
import { link, loading } from '#shared/components.util';
import { Content, iconByType } from '~~/shared/content';
import { dom, icon } from '~~/shared/dom';
import { unifySlug } from '~~/shared/general';
import { tooltip } from '~~/shared/floating';
import { link, loading } from '~~/shared/components';
import { breakpoint } from '~~/shared/breakpoint';
const open = ref(false);
const open = useLocalStorage('sidebar', true, { writeDefaults: true });
let tree: TreeDOM | undefined;
const { loggedIn, user } = useUserSession();
function onSidebarClick(e: MouseEvent)
{
const link = (e.target as HTMLElement).closest('a');
if (link && breakpoint.viewport !== 'xl' && breakpoint.viewport !== '2xl')
nextTick(() => { open.value = false; });
}
const route = useRouter().currentRoute;
const path = computed(() => route.value.params.path ? decodeURIComponent(unifySlug(route.value.params.path)) : undefined);
const unmount = useRouter().afterEach((to, from, failure) => {
if(failure)
return;
@@ -73,10 +91,6 @@ const unmount = useRouter().afterEach((to, from, failure) => {
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, () => {
open.value = false;
});
const treeParent = useTemplateRef('treeParent');
onMounted(() => {
if(treeParent.value)
@@ -84,13 +98,13 @@ onMounted(() => {
treeParent.value.replaceChildren(loading('normal'));
Content.ready.then(() => {
tree = new TreeDOM((item, depth) => {
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 } }, [
return dom('div', { class: 'group flex items-center ps-2 outline-hidden 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 / 1.5 - 1}em` } }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined,
])]);
}, (item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [link([
return dom('div', { class: 'group flex items-center ps-2 outline-hidden relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [link([
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 } }),
item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined,

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-1 items-center justify-center">
<div class="w-full md:w-auto h-full border-e border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 md:p-8 xl:p-16 flex justify-center items-center">
<div class="w-full md:w-[48rem] h-full border-e border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 md:p-8 xl:p-16 flex justify-center items-center">
<slot />
</div>
<div class="hidden md:block flex-auto h-full"></div>

View File

@@ -1,4 +1,4 @@
import { hasPermissions } from "#shared/auth.util";
import { hasPermissions } from "#shared/auth";
export default defineNuxtRouteMiddleware(async (to, from) => {
const { loggedIn, fetch, user } = useUserSession();

View File

@@ -31,10 +31,10 @@
</script>
<script setup lang="ts">
import { format } from '#shared/general.util';
import { iconByType } from '#shared/content.util';
import { format } from '~~/shared/general';
import { iconByType } from '~~/shared/content';
import { Icon } from '@iconify/vue';
import { Toaster } from '#shared/components.util';
import { Toaster } from '~~/shared/components';
interface File
{
@@ -199,7 +199,7 @@ async function logout(user: User)
</DialogTitle>
<div class="flex flex-1 justify-end gap-4">
<DialogClose asChild><Button>Non</Button></DialogClose>
<DialogClose asChild><Button @click="() => logout(user)" class="border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green hover:bg-light-greenBack dark:hover:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green dark:focus:shadow-dark-green">Oui</Button></DialogClose>
<DialogClose asChild><Button @click="() => logout(user)" class="border-light-green dark:border-dark-green hover:border-light-green hover:dark:border-dark-green hover:bg-light-greenBack hover:dark:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green focus:dark:shadow-dark-green">Oui</Button></DialogClose>
</div>
</DialogContent>
</DialogPortal>
@@ -216,7 +216,7 @@ async function logout(user: User)
<AlertDialogDescription><TagsInput v-model="permissionCopy" /></AlertDialogDescription>
<div class="flex flex-1 justify-end gap-4">
<AlertDialogCancel asChild><Button>Annuler</Button></AlertDialogCancel>
<AlertDialogAction asChild><Button @click="() => editPermissions(user)" class="border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green hover:bg-light-greenBack dark:hover:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green dark:focus:shadow-dark-green">Modifier</Button></AlertDialogAction>
<AlertDialogAction asChild><Button @click="() => editPermissions(user)" class="border-light-green dark:border-dark-green hover:border-light-green hover:dark:border-dark-green hover:bg-light-greenBack hover:dark:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green focus:dark:shadow-dark-green">Modifier</Button></AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>

View File

@@ -15,7 +15,8 @@ const schemaList: Record<string, z.ZodObject<any> | null> = {
<script setup lang="ts">
import { z } from 'zod/v4';
import { Icon } from '@iconify/vue';
import { Toaster } from '#shared/components.util';
import { Toaster } from '~~/shared/components';
import { Content } from '~~/shared/content';
definePageMeta({
rights: ['admin'],
@@ -51,6 +52,9 @@ async function fetch()
error.value = null;
success.value = true;
if(job.value === 'pull')
await Content.pull(true);
Toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, });
}
catch(e)
@@ -70,7 +74,7 @@ async function fetch()
</Head>
<div class="flex flex-col justify-start items-center p-4">
<div class="flex flex-row justify-between items-center gap-8">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<span class="border border-transparent hover:border-light-35 hover:dark:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<h2 class="text-center flex-1 text-2xl font-bold">Administration</h2>
</div>
<div class="flex flex-row w-full gap-8">
@@ -83,7 +87,7 @@ async function fetch()
</div>
<div v-if="job === 'mail'" class="flex justify-center items-center flex-col">
<TextInput label="Destinataire" class="w-full" v-model="payload.to" />
<textarea v-model="payload.data" class="w-[640px] bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none m-2 px-2"></textarea>
<textarea v-model="payload.data" class="w-[640px] bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-hidden m-2 px-2"></textarea>
</div>
<Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'">
<span>Executer</span>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { unifySlug } from '#shared/general.util';
import { CampaignSheet } from '#shared/campaign.util';
import { unifySlug } from '~~/shared/general';
import { CampaignSheet } from '~~/shared/campaign';
definePageMeta({
requiresAuth: true,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Toaster } from '#shared/components.util';
import { Toaster } from '~~/shared/components';
definePageMeta({
requiresAuth: true,
@@ -65,7 +65,7 @@ function create()
<span class="text-sm">{{ campaign.members.length }} joueur{{ campaign.members.length === 1 ? '' : 's' }}</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 class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100/10 dark:bg-dark-100/10"></div>
</div>
</NuxtLink>
<div class="flex justify-around items-center py-2 px-4 gap-4">
@@ -80,7 +80,7 @@ function create()
<AlertDialogTitle class="text-3xl font-light relative -top-2">Vous vous appretez à {{ user.id !== campaign.owner.id ? 'quitter' : 'supprimer' }} "{{ campaign.name }}". Etes vous sûr ?</AlertDialogTitle>
<div class="flex flex-1 justify-end gap-4">
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
<AlertDialogAction asChild><Button @click="() => user?.id !== campaign.owner.id ? leaveCampaign(campaign.id) : removeCampaign(campaign.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>
<AlertDialogAction asChild><Button @click="() => user?.id !== campaign.owner.id ? leaveCampaign(campaign.id) : removeCampaign(campaign.id)" class="border-light-red dark:border-dark-red hover:border-light-red hover:dark:border-dark-red hover:bg-light-redBack hover:dark:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red focus:dark:shadow-dark-red">Oui</Button></AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
@@ -100,7 +100,7 @@ function create()
<span class="text-sm">{{ campaign.members.length }} joueur{{ campaign.members.length === 1 ? '' : 's' }}</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 class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100/10 dark:bg-dark-100/10"></div>
</div>
</NuxtLink>
<div class="flex justify-around items-center py-2 px-4 gap-4">
@@ -115,7 +115,7 @@ function create()
<AlertDialogTitle class="text-3xl font-light relative -top-2">Vous vous appretez à {{ user.id !== campaign.owner.id ? 'quitter' : 'supprimer' }} "{{ campaign.name }}". Etes vous sûr ?</AlertDialogTitle>
<div class="flex flex-1 justify-end gap-4">
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
<AlertDialogAction asChild><Button @click="() => user?.id !== campaign.owner.id ? leaveCampaign(campaign.id) : removeCampaign(campaign.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>
<AlertDialogAction asChild><Button @click="() => user?.id !== campaign.owner.id ? leaveCampaign(campaign.id) : removeCampaign(campaign.id)" class="border-light-red dark:border-dark-red hover:border-light-red hover:dark:border-dark-red hover:bg-light-redBack hover:dark:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red focus:dark:shadow-dark-red">Oui</Button></AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
@@ -126,14 +126,14 @@ function create()
</div>
<div v-else class="flex flex-col gap-2 items-center flex-1">
<span class="text-lg font-bold">Vous n'avez pas encore rejoint de campagne</span>
<div class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
<div class="inline-flex justify-center items-center outline-hidden leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" @click="create">Créer ma campagne</div>
<!-- <NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
hover:bg-light-25 hover:dark:bg-dark-25 hover:border-light-50 hover:dark:border-dark-50
focus:bg-light-30 focus:dark:bg-dark-30 focus:border-light-50 focus:dark:border-dark-50 focus:shadow-raw focus:shadow-light-50 focus:dark:shadow-dark-50 py-2 px-4" @click="create">Créer ma campagne</div>
<!-- <NuxtLink class="inline-flex justify-center items-center outline-hidden leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'campaign-id-edit', params: { id: 'new' } }">Créer ma campagne</NuxtLink> -->
hover:bg-light-25 hover:dark:bg-dark-25 hover:border-light-50 hover:dark:border-dark-50
focus:bg-light-30 focus:dark:bg-dark-30 focus:border-light-50 focus:dark:border-dark-50 focus:shadow-raw focus:shadow-light-50 focus:dark:shadow-dark-50 py-2 px-4" :to="{ name: 'campaign-id-edit', params: { id: 'new' } }">Créer ma campagne</NuxtLink> -->
</div>
</template>
<div v-else>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { CharacterBuilder } from '#shared/character.util';
import { unifySlug } from '#shared/general.util';
import { CharacterBuilder } from '~~/shared/character';
import { unifySlug } from '~~/shared/general';
definePageMeta({
requiresAuth: true,

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import characterConfig from '#shared/character-config.json';
import { unifySlug } from '#shared/general.util';
import { unifySlug } from '~~/shared/general';
import type { CharacterConfig } from '~/types/character';
import { CharacterSheet } from '#shared/character.util';
import { CharacterSheet } from '~~/shared/character';
/*
text-light-red dark:text-dark-red border-light-red dark:border-dark-red bg-light-red dark:bg-dark-red

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import characterConfig from '#shared/character-config.json';
import { Toaster } from '#shared/components.util';
import { Toaster } from '~~/shared/components';
import type { CharacterConfig } from '~/types/character';
definePageMeta({
@@ -51,11 +51,11 @@ async function duplicateCharacter(id: number)
<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 class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100/10 dark:bg-dark-100/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>
<NuxtLink :to="{ name: 'character-id', 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>
@@ -70,7 +70,7 @@ async function duplicateCharacter(id: number)
<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>
<AlertDialogAction asChild><Button @click="() => deleteCharacter(character.id)" class="border-light-red dark:border-dark-red hover:border-light-red hover:dark:border-dark-red hover:bg-light-redBack hover:dark:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red focus:dark:shadow-dark-red">Oui</Button></AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
@@ -80,15 +80,15 @@ async function duplicateCharacter(id: number)
</div>
<div v-else class="flex flex-col gap-2 items-center flex-1">
<span class="text-lg font-bold">Vous n'avez pas encore de personnage</span>
<NuxtLink v-if="user && user.state === 1" class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
<NuxtLink v-if="user && user.state === 1" class="inline-flex justify-center items-center outline-hidden leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink>
hover:bg-light-25 hover:dark:bg-dark-25 hover:border-light-50 hover:dark:border-dark-50
focus:bg-light-30 focus:dark:bg-dark-30 focus:border-light-50 focus:dark:border-dark-50 focus:shadow-raw focus:shadow-light-50 focus:dark:shadow-dark-50 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink>
<div v-else>Veuillez validez votre adresse mail pour pouvoir créer des personnages.</div>
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
<NuxtLink class="inline-flex justify-center items-center outline-hidden leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'character-list' }">Qu'ont fait les autres ?</NuxtLink>
hover:bg-light-25 hover:dark:bg-dark-25 hover:border-light-50 hover:dark:border-dark-50
focus:bg-light-30 focus:dark:bg-dark-30 focus:border-light-50 focus:dark:border-dark-50 focus:shadow-raw focus:shadow-light-50 focus:dark:shadow-dark-50 py-2 px-4" :to="{ name: 'character-list' }">Qu'ont fait les autres ?</NuxtLink>
</div>
</template>
<div v-else>

View File

@@ -29,7 +29,7 @@ const { user } = useUserSession();
<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 class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100/10 dark:bg-dark-100/10"></div>
</div>
</NuxtLink>
</div>
@@ -38,10 +38,10 @@ const { user } = useUserSession();
<span class="text-lg font-bold">Il n'existe pas encore de personnage public</span>
<template v-if="user && user.state === 1">
Soyez le premier à partager vos créations !
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
<NuxtLink class="inline-flex justify-center items-center outline-hidden leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink>
hover:bg-light-25 hover:dark:bg-dark-25 hover:border-light-50 hover:dark:border-dark-50
focus:bg-light-30 focus:dark:bg-dark-30 focus:border-light-50 focus:dark:border-dark-50 focus:shadow-raw focus:shadow-light-50 focus:dark:shadow-dark-50 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink>
</template>
<div v-else>Veuillez valider votre adresse mail pour pouvoir créer des personnages.</div>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { HomebrewBuilder } from '#shared/feature.util';
import { HomebrewBuilder } from '#shared/feature';
definePageMeta({

View File

@@ -7,8 +7,8 @@
</template>
<script setup lang="ts">
import { Content } from '#shared/content.util';
import { unifySlug } from '#shared/general.util';
import { Content } from '~~/shared/content';
import { unifySlug } from '~~/shared/general';
const element = useTemplateRef('element'), overview = ref();
const route = useRouter().currentRoute;

View File

@@ -23,11 +23,11 @@
<NavigationMenuList class="flex items-center gap-8 max-md:hidden">
<NavigationMenuItem>
<NavigationMenuTrigger>
<NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Personnages</span><Icon icon="radix-icons:caret-down" /></NuxtLink>
<NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="text-accent-blue"><span class="px-3 flex-1 truncate">Personnages</span><Icon icon="radix-icons:caret-down" /></NuxtLink>
</NavigationMenuTrigger>
<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="hover:bg-light-30 dark:hover:bg-dark-30 px-4 py-2 select-none" active-class="!text-accent-blue"><span class="flex-1 truncate">Personnages publics</span></NuxtLink>
<NuxtLink :href="{ name: 'character-id-edit', params: { id: 'new' } }" class="hover:bg-light-30 dark:hover:bg-dark-30 px-4 py-2 select-none" active-class="!text-accent-blue"><span class="flex-1 truncate">Nouveau personnage</span></NuxtLink>
<NuxtLink :href="{ name: 'character-list' }" class="hover:bg-light-30 hover:dark:bg-dark-30 px-4 py-2 select-none" active-class="text-accent-blue"><span class="flex-1 truncate">Personnages publics</span></NuxtLink>
<NuxtLink :href="{ name: 'character-id-edit', params: { id: 'new' } }" class="hover:bg-light-30 hover:dark:bg-dark-30 px-4 py-2 select-none" active-class="text-accent-blue"><span class="flex-1 truncate">Nouveau personnage</span></NuxtLink>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
@@ -35,10 +35,10 @@
<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>
</NavigationMenuRoot>
<NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Campagnes</span></NuxtLink>
<NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="text-accent-blue"><span class="px-3 flex-1 truncate">Campagnes</span></NuxtLink>
</div>
<div class="flex flex-row gap-16 items-center">
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 hover:dark:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
</div>
</div>
<div class="flex flex-1 flex-row max-h-full overflow-hidden" ref="container"></div>
@@ -47,11 +47,11 @@
</template>
<script setup lang="ts">
import { Content, Editor } from '#shared/content.util';
import { button, loading } from '#shared/components.util';
import { dom, icon } from '#shared/dom.util';
import { modal, tooltip } from '#shared/floating.util';
import { Toaster } from '#shared/components.util';
import { Content, Editor } from '~~/shared/content';
import { button, loading } from '~~/shared/components';
import { dom, icon } from '~~/shared/dom';
import { modal, tooltip } from '~~/shared/floating';
import { Toaster } from '~~/shared/components';
import { Icon } from '@iconify/vue';
definePageMeta({
@@ -92,8 +92,10 @@ onMounted(async () => {
tree.value.appendChild(load);
const content = dom('div', { class: 'flex flex-row justify-start items-center gap-4 p-2' }, [
tooltip(button(icon('ph:cloud-arrow-down', { height: 20, width: 20 }), pull, 'p-1'), 'Actualiser', 'top'),
tooltip(button(icon('ph:cloud-arrow-up', { height: 20, width: 20 }), push, 'p-1'), 'Enregistrer', 'top'),
tooltip(button(icon('ph:cloud-arrow-down', { height: 16, width: 16 }), pull, 'p-1'), 'Actualiser', 'top'),
tooltip(button(icon('ph:cloud-arrow-up', { height: 16, width: 16 }), push, 'p-1'), 'Enregistrer', 'top'),
tooltip(button(icon('radix-icons:reset', { height: 16, width: 16 }), () => editor?.undo(), 'p-1'), 'Annuler', 'top'),
tooltip(button(icon('radix-icons:reset', { height: 16, width: 16, hFlip: true }), () => editor?.redo(), 'p-1'), 'Rétablir', 'top'),
])
tree.value.insertBefore(content, load);
@@ -105,6 +107,11 @@ onMounted(async () => {
}
});
useShortcuts({
"Meta-Z": () => editor?.undo(),
"Meta-Y": () => editor?.redo(),
});
onBeforeUnmount(() => {
editor?.unmount();
});

View File

@@ -4,7 +4,7 @@
</Head>
<div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<span class="border border-transparent hover:border-light-35 hover:dark:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<h4 class="text-xl font-bold">Reinitialisation de mon mot de passe</h4>
</div>
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">

View File

@@ -4,7 +4,7 @@
</Head>
<div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<span class="border border-transparent hover:border-light-35 hover:dark:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<h4 class="text-center flex-1 text-xl font-bold">Reinitialisation de mon mot de passe</h4>
</div>
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
@@ -25,7 +25,7 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { Toaster } from '#shared/components.util';
import { Toaster } from '~~/shared/components';
definePageMeta({
layout: 'login',

View File

@@ -4,7 +4,7 @@
</Head>
<div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<span class="border border-transparent hover:border-light-35 hover:dark:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<h4 class="text-center flex-1 text-xl font-bold">Modification de mon mot de passe</h4>
</div>
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
@@ -26,7 +26,7 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { Toaster } from '#shared/components.util';
import { Toaster } from '~~/shared/components';
definePageMeta({
layout: 'login',

View File

@@ -4,7 +4,7 @@
</Head>
<div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<span class="border border-transparent hover:border-light-35 hover:dark:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<h4 class="text-xl font-bold">Connexion</h4>
</div>
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
@@ -21,7 +21,7 @@
import type { ZodError } from 'zod/v4';
import { schema, type Login } from '~/schemas/login';
import { Icon } from '@iconify/vue';
import { Toaster } from '#shared/components.util';
import { Toaster } from '~~/shared/components';
definePageMeta({
layout: 'login',

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { hasPermissions } from "#shared/auth.util";
import { Toaster } from '#shared/components.util';
import { hasPermissions } from "#shared/auth";
import { Toaster } from '~~/shared/components';
definePageMeta({
requiresAuth: true,
@@ -57,7 +57,7 @@ async function deleteUser()
<NuxtLink :to="{ name: 'user-changing-password' }" class="flex flex-1"><Button>Modifier mon mot de passe</Button></NuxtLink>
<AlertDialogRoot>
<AlertDialogTrigger asChild><Button :loading="loading"
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">Supprimer
class="border-light-red dark:border-dark-red hover:border-light-red hover:dark:border-dark-red hover:bg-light-redBack hover:dark:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red focus:dark:shadow-dark-red">Supprimer
mon compte</Button></AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
@@ -72,7 +72,7 @@ async function deleteUser()
</AlertDialogDescription>
<div class="flex flex-1 justify-end gap-4">
<AlertDialogCancel asChild><Button>Annuler</Button></AlertDialogCancel>
<AlertDialogAction asChild><Button @click="() => deleteUser()" 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">Supprimer</Button></AlertDialogAction>
<AlertDialogAction asChild><Button @click="() => deleteUser()" class="border-light-red dark:border-dark-red hover:border-light-red hover:dark:border-dark-red hover:bg-light-redBack hover:dark:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red focus:dark:shadow-dark-red">Supprimer</Button></AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>

View File

@@ -4,7 +4,7 @@
</Head>
<div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<span class="border border-transparent hover:border-light-35 hover:dark:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<h4 class="text-xl font-bold">Inscription</h4>
</div>
<form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0">
@@ -20,7 +20,7 @@
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
</div>
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
<Label class="pb-2 col-span-2 md:col-span-1 flex flex-row gap-2 items-center"><CheckboxRoot v-model:checked="agreeOnRules" class="border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 w-5 h-5" ><CheckboxIndicator ><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span>J'ai lu et j'accepte les <NuxtLink class="text-accent-blue cursor-pointer" :to="{ name: 'usage' }" target="_blank">conditions d'utilisation</NuxtLink></span></Label>
<Label class="pb-2 col-span-2 md:col-span-1 flex flex-row gap-2 items-center"><CheckboxRoot v-model:checked="agreeOnRules" class="border border-light-35 dark:border-dark-35 hover:border-light-50 hover:dark:border-dark-50 w-5 h-5" ><CheckboxIndicator ><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span>J'ai lu et j'accepte les <NuxtLink class="text-accent-blue cursor-pointer" :to="{ name: 'usage' }" target="_blank">conditions d'utilisation</NuxtLink></span></Label>
<Button type="submit" class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
<span class="mt-4 order-10 flex justify-center items-center gap-4 col-span-2 md:col-span-1 m-auto">Vous avez déjà un compte ?<NuxtLink class="text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-login' }">Se connecter</NuxtLink></span>
</form>
@@ -31,7 +31,7 @@
import { ZodError } from 'zod/v4';
import { schema, type Registration } from '~/schemas/registration';
import { Icon } from '@iconify/vue';
import { Toaster } from '#shared/components.util';
import { Toaster } from '~~/shared/components';
definePageMeta({
layout: 'login',

View File

@@ -1,7 +0,0 @@
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('autofocus', {
mounted(el, binding) {
el.focus();
}
})
})

View File

@@ -1,8 +1,8 @@
import { z } from "zod";
export const schema = z.object({
usernameOrEmail: z.string({ required_error: "Nom d'utilisateur ou email obligatoire" }),
password: z.string({ required_error: "Mot de passe obligatoire" }),
usernameOrEmail: z.string({ error: "Nom d'utilisateur ou email obligatoire" }),
password: z.string({ error: "Mot de passe obligatoire" }),
});
export type Login = z.infer<typeof schema>;

View File

@@ -1,4 +1,4 @@
import type { MAIN_STATS, ABILITIES, LEVELS, TRAINING_LEVELS, SPELL_TYPES, CATEGORIES, SPELL_ELEMENTS, ALIGNMENTS, RESISTANCES, DAMAGE_TYPES, WEAPON_TYPES, PropertySum, ITEM_BUFFER_KEYS } from "#shared/character.util";
import type { MAIN_STATS, ABILITIES, LEVELS, TRAINING_LEVELS, SPELL_TYPES, CATEGORIES, SPELL_ELEMENTS, ALIGNMENTS, RESISTANCES, DAMAGE_TYPES, WEAPON_TYPES, CRAFTING_TYPES, PropertySum, ITEM_BUFFER_KEYS } from "#shared/character";
import type { Localized } from "../types/general";
export type MainStat = typeof MAIN_STATS[number];
@@ -12,8 +12,10 @@ export type Alignment = typeof ALIGNMENTS[number];
export type Resistance = typeof RESISTANCES[number];
export type DamageType = typeof DAMAGE_TYPES[number];
export type WeaponType = typeof WEAPON_TYPES[number];
export type CraftingType = typeof CRAFTING_TYPES[number];
export type FeatureID = string;
export type FeatureEffectID = string;
export type i18nID = string;
export type RecursiveKeyOf<TObj extends object> = {
@@ -55,29 +57,37 @@ export type CharacterVariables = {
spells: string[]; //Spell ID
items: ItemState[];
money: number;
components: { money: number, natural: number, mineral: number, processed: number, magical: number };
transformed: boolean;
craft?: { item: string, progress: number };
};
export type TreeLeaf = {
id: FeatureID;
to?: FeatureID | Array<FeatureID> | Record<string, FeatureID>;
flags?: number; //Flags from TreeFlag
};
export type TreeStructure = {
name: string;
nodes: FeatureID[];
paths: Record<number, number | number[]>;
start: FeatureID | Array<FeatureID> | Record<string, FeatureID>;
nodes: Record<FeatureID, TreeLeaf>;
};
type CommonState = {
capacity?: number;
powercost?: number;
analysed?: boolean;
};
type ArmorState = { loss: number, health?: number, absorb?: { flat?: number, percent?: number } };
type ArmorState = { loss: number, health?: number, absorb: { flat?: number, percent?: number } };
type WeaponState = { attack?: number | string, hit?: number };
type WondrousState = { };
type MundaneState = { };
type ItemState = {
id: string;
amount: number;
enchantments?: string[];
improvements?: string[];
charges?: number;
equipped?: boolean;
state?: (ArmorState | WeaponState | WondrousState | MundaneState) & CommonState;
buffer?: Record<string, PropertySum>
};
export type CharacterConfig = {
peoples: Record<string, RaceConfig>;
@@ -85,27 +95,30 @@ export type CharacterConfig = {
spells: Record<string, SpellConfig>;
aspects: Record<string, AspectConfig>;
features: Record<FeatureID, Feature>;
enchantments: Record<string, EnchantementConfig>;
improvements: Record<string, ImprovementConfig>;
items: Record<string, ItemConfig>;
action: Record<string, { id: string, name: string, description: string, cost: number }>;
reaction: Record<string, { id: string, name: string, description: string, cost: number }>;
freeaction: Record<string, { id: string, name: string, description: string }>;
passive: Record<string, { id: string, name: string, description: string }>;
action: Record<string, { id: string, name: string, description: string, cost?: number, variants?: string[], parent?: string }>;
reaction: Record<string, { id: string, name: string, description: string, cost?: number, variants?: string[], parent?: string }>;
freeaction: Record<string, { id: string, name: string, description: string, variants?: string[], parent?: string }>;
passive: Record<string, { id: string, name: string, description: string, variants?: string[], parent?: string }>;
texts: Record<i18nID, Localized>;
trees: Record<string, TreeStructure>;
//Each of these groups extend an existing feature as they all use the same properties
sickness: Record<FeatureID, { name: string, stage: number }>; //TODO
poisons: Record<FeatureID, { name: string, difficulty: number, efficienty: number, solubility: number }>; //TODO
dedications: Record<FeatureID, { name: string, requirement: Array<{ stat: MainStat, amount: number }> }>; //TODO
poison: Record<FeatureID, { name: string, difficulty: number, efficienty: number, solubility: number }>; //TODO
dedication: Record<FeatureID, { name: string, requirement: Array<{ stat: MainStat, amount: number }> }>; //TODO
};
export type EnchantementConfig = {
export type ImprovementConfig = {
id: string;
name: string; //TODO -> TextID
description: i18nID;
rarity: 'common' | 'uncommon' | 'rare' | 'veryrare' | 'legendary';
effect: Array<FeatureEquipment | FeatureValue | FeatureList>;
power: number;
restrictions?: Array<'armor' | 'mundane' | 'wondrous' | 'weapon' | `armor/${ArmorConfig['type']}` | `weapon/${WeaponConfig['type'][number]}`>; // Need to respect *any* of the restriction, not every restrictions.
craft: { difficulty?: number, ability?: CraftingType };
restrictions?: Partial<Record<'armor' | 'mundane' | 'wondrous' | 'weapon' | `armor/${ArmorConfig['type']}` | `weapon/${WeaponConfig['type'][number]}` | string, boolean>>; // Need to respect *any* of the restriction, not every restrictions.
cursed: boolean;
}
export type ItemConfig = CommonItemConfig & (ArmorConfig | WeaponConfig | WondrousConfig | MundaneConfig);
type CommonItemConfig = {
@@ -113,18 +126,19 @@ type CommonItemConfig = {
name: string; //TODO -> TextID
flavoring?: i18nID;
description: i18nID;
rarity: 'common' | 'uncommon' | 'rare' | 'legendary';
rarity: 'common' | 'uncommon' | 'rare' | 'veryrare' | 'legendary';
weight?: number; //Optionnal but highly recommended
price?: number; //Optionnal but highly recommended
capacity?: number; //Optionnal as most mundane items should not receive enchantments (potions, herbal heals, etc...)
capacity?: number; //Optionnal as most mundane items should not receive improvements (potions, herbal heals, etc...)
powercost?: number; //Optionnal
charge?: number //Max amount of charges
enchantments?: string[]; //Enchantment ID
effects?: Array<FeatureValue | FeatureEquipment | FeatureList>;
improvements?: string[]; //Improvement ID
effects?: Array<FeatureValue | FeatureState | FeatureEquipment | FeatureList>;
equippable: boolean;
consummable: boolean;
craft?: { mineral: number, natural: number, processed: number, magical: number };
}
craft: { mineral: number, natural: number, processed: number, magical: number, difficulty?: number, ability?: CraftingType };
variants?: string[]; //ID array
};
type ArmorConfig = {
category: 'armor';
health: number;
@@ -151,22 +165,13 @@ export type SpellConfig = {
rank: 1 | 2 | 3 | 4;
type: SpellType;
cost: number;
speed: "action" | "reaction" | number;
speed: "action" | "reaction" | "channeling" | number;
elements: Array<SpellElement>;
description: string; //TODO -> TextID
concentration: boolean;
range: 'personnal' | number;
tags?: string[];
};
export type ArtConfig = {
id: string;
name: string; //TODO -> TextID
rank: 1 | 2 | 3;
type: "arts";
difficulty: number;
description: string; //TODO -> TextID
tags?: string[];
};
export type RaceConfig = {
id: string;
name: string; //TODO -> TextID
@@ -188,34 +193,41 @@ export type AspectConfig = {
};
export type FeatureValue = {
id: FeatureID;
id: FeatureEffectID;
category: "value";
operation: "add" | "set" | "min";
property: RecursiveKeyOf<CompiledCharacter> | 'spec' | 'ability' | 'training';
value: number | `modifier/${MainStat}` | false;
}
export type FeatureState = {
id: FeatureEffectID;
category: "state";
property: RecursiveKeyOf<CompiledCharacter>;
value: string;
}
export type FeatureEquipment = {
id: FeatureID;
id: FeatureEffectID;
category: "value";
operation: "add" | "set" | "min";
property: `item/${RecursiveKeyOf<ArmorState & WeaponState & WondrousState & MundaneState & CommonState>}`;
value: number | `modifier/${MainStat}` | false;
};
export type FeatureList = {
id: FeatureID;
id: FeatureEffectID;
category: "list";
list: "spells" | "sickness" | "action" | "reaction" | "freeaction" | "passive" | "mastery";
list: "spells" | "sickness" | "action" | "reaction" | "freeaction" | "passive" | "mastery" | "poison" | "dedication";
action: "add" | "remove";
item: string;
};
export type FeatureTree = {
id: FeatureID;
id: FeatureEffectID;
category: "tree";
tree: string;
option?: number;
option?: string;
priority?: number;
};
export type FeatureChoice = {
id: FeatureID;
id: FeatureEffectID;
category: "choice";
text: string; //TODO -> TextID
settings?: { //If undefined, amount is 1 by default
@@ -224,7 +236,7 @@ export type FeatureChoice = {
};
options: Array<{ text: string, effects: Array<FeatureValue | FeatureList | FeatureTree> }>; //TODO -> TextID
};
export type FeatureItem = FeatureValue | FeatureList | FeatureChoice | FeatureTree;
export type FeatureItem = FeatureValue | FeatureState | FeatureList | FeatureChoice | FeatureTree;
export type Feature = {
id: FeatureID;
description: string; //TODO -> TextID
@@ -241,7 +253,7 @@ export type CompiledCharacter = {
race: string;
spellslots: number; //Max
artslots: number; //Max
spellranks: Record<SpellType | 'arts', 0 | 1 | 2 | 3>;
spellranks: Record<SpellType, 0 | 1 | 2 | 3>;
aspect: {
id: string,
amount: number;
@@ -279,6 +291,7 @@ export type CompiledCharacter = {
};
weapon: Partial<Record<WeaponType, number>>;
resistance: Partial<Record<Resistance, number>>; //Bonus à l'attaque
damage: Partial<DamageType, 'resistance' | 'immunity' | 'vulnerability'>;
}; //Any special bonus goes here
craft: { level: number, bonus: number };
@@ -286,7 +299,7 @@ export type CompiledCharacter = {
modifier: Record<MainStat, number>;
abilities: Partial<Record<Ability, number>>;
level: number;
lists: { [K in FeatureList['list']]?: string[] }; //string => ListItem ID
lists: { [K in Exclude<FeatureList['list'], 'mastery'>]: string[] }; //string => ListItem ID
notes: { public: string, private: string };
};

2260
bun.lock

File diff suppressed because it is too large Load Diff

BIN
db.sqlite

Binary file not shown.

View File

@@ -1,6 +1,7 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import fs from 'node:fs'
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
export default defineNuxtConfig({
compatibilityDate: '2024-04-03',
@@ -8,113 +9,11 @@ export default defineNuxtConfig({
modules: [
'@nuxtjs/color-mode',
'nuxt-security',
'@nuxtjs/tailwindcss',
'@vueuse/nuxt',
'radix-vue/nuxt',
'@nuxtjs/sitemap',
],
tailwindcss: {
viewer: false,
config: {
content: {
files: [
"./shared/**/*.{vue,js,jsx,mjs,ts,tsx}"
]
},
theme: {
extend: {
boxShadow: {
raw: '0 0 0 2px var(--tw-shadow-color)'
},
keyframes: {
slideDownAndFade: {
from: { opacity: '0', transform: 'translateY(-2px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
slideLeftAndFade: {
from: { opacity: '0', transform: 'translateX(2px)' },
to: { opacity: '1', transform: 'translateX(0)' },
},
slideUpAndFade: {
from: { opacity: '0', transform: 'translateY(2px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
slideRightAndFade: {
from: { opacity: '0', transform: 'translateX(-2px)' },
to: { opacity: '1', transform: 'translateX(0)' },
},
contentShow: {
from: { opacity: '0', transform: 'translate(-50%, -48%) scale(0.96)' },
to: { opacity: '1', transform: 'translate(-50%, -50%) scale(1)' },
},
},
animation: {
slideDownAndFade: 'slideDownAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
slideLeftAndFade: 'slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
slideUpAndFade: 'slideUpAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
slideRightAndFade: 'slideRightAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
contentShow: 'contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1)',
},
},
colors: {
transparent: 'transparent',
current: 'currentColor',
light: {
red: '#e93147',
orange: '#FF9800',
yellow: '#FFEB3B',
green: '#388E3C',
indigo: '#7986CB',
cyan: '#00bfbc',
lime: '#8BC34A',
blue: '#086ddd',
purple: '#AB47BC',
pink: '#d53984',
0: "#ffffff",
5: "#fcfcfc",
10: "#fafafa",
20: "#f7f7f7",
25: "#e4e4e4",
30: "#dfdfdf",
35: "#d2d2d2",
40: "#bdbdbd",
50: "#ababab",
60: "#707070",
70: "#5c5c5c",
100: "#202020",
},
dark: {
red: '#fb464c',
redBack: '#5A292B',
orange: '#e9973f',
yellow: '#e0de71',
green: '#44cf6e',
greenBack: '#284E34',
cyan: '#53dfdd',
blue: '#027aff',
purple: '#a882ff',
pink: '#fa99cd',
0: '#1e1e1e',
5: '#212121',
10: '#242424',
20: '#262626',
25: '#2a2a2a',
30: '#363636',
35: '#3f3f3f',
40: '#555555',
50: '#666666',
60: '#999999',
70: '#b3b3b3',
100: '#dadada',
},
accent: {
purple: '#43A047',
blue: '#26C6DA',
},
}
}
}
},
css: ['~/assets/css/main.css'],
app: {
pageTransition: false,
layoutTransition: false
@@ -151,8 +50,46 @@ export default defineNuxtConfig({
passwd: '',
}
},
routeRules: {
'/api/auth/session': {
security: {
rateLimiter: {
headers: true,
interval: 100,
tokensPerInterval: 3
},
}
},
'/api/auth/login': {
security: {
rateLimiter: {
headers: true,
interval: 1000,
tokensPerInterval: 1
},
}
},
'/api/auth/register': {
security: {
rateLimiter: {
headers: true,
interval: 1000,
tokensPerInterval: 1
},
}
},
'/api/file/**': {
security: {
rateLimiter: false,
}
}
},
security: {
rateLimiter: false,
rateLimiter: {
headers: true,
interval: 1000,
tokensPerInterval: 10
},
headers: {
contentSecurityPolicy: {
"img-src": "'self' data: blob:",
@@ -164,11 +101,13 @@ export default defineNuxtConfig({
},
},
sitemap: {
exclude: ['/admin/**', '/explore/edit', '/user/mailvalidated', '/user/changing-password', '/user/reset-password', '/character/manage', '/campaign/create'],
exclude: ['/admin/**', '/explore/edit', '/user/**', '/character/**', '/campaign/**'],
sources: ['/api/__sitemap__/urls']
},
experimental: {
noVueServer: true,
componentIslands: {
selectiveClient: true,
},
defaults: {
nuxtLink: {
prefetchOn: {
@@ -185,6 +124,9 @@ export default defineNuxtConfig({
}
},
vite: {
plugins: [
tailwindcss(),
],
server: {
hmr: {
protocol: 'wss',
@@ -194,6 +136,43 @@ export default defineNuxtConfig({
path: '/ws'
},
},
optimizeDeps: {
include: [
'@atlaskit/pragmatic-drag-and-drop-auto-scroll/element',
'@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item',
'@atlaskit/pragmatic-drag-and-drop/combine',
'@atlaskit/pragmatic-drag-and-drop/element/adapter',
'@codemirror/autocomplete',
'@codemirror/commands',
'@codemirror/lang-markdown',
'@codemirror/language',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@floating-ui/dom',
'@iconify/vue',
'@lezer/common',
'@lezer/highlight',
'hast-util-heading',
'hast-util-heading-rank',
'hast-util-select',
'iconify-icon',
'lodash.capitalize',
'mdast-util-find-and-replace',
'mdast-util-to-string',
'remark-breaks',
'remark-frontmatter',
'remark-gfm',
'remark-parse',
'remark-rehype',
'remark-stringify',
'strip-markdown',
'unified',
'unist-util-visit',
'zod',
'zod/v4',
]
},
},
vue: {
compilerOptions: {

View File

@@ -9,22 +9,22 @@
"migrate": "bun migrate.ts"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0",
"@codemirror/lang-markdown": "^6.5.0",
"@floating-ui/dom": "^1.7.4",
"@iconify/vue": "^5.0.0",
"@floating-ui/dom": "^1.7.6",
"@iconify/vue": "^5.0.1",
"@lezer/highlight": "^1.2.3",
"@markdoc/markdoc": "^0.5.4",
"@markdoc/markdoc": "^0.5.7",
"@nuxtjs/color-mode": "^4.0.0",
"@nuxtjs/sitemap": "^7.4.9",
"@nuxtjs/tailwindcss": "^6.14.0",
"@nuxtjs/sitemap": "^8.1.0",
"@tailwindcss/vite": "^4.1.18",
"@vueuse/gesture": "^2.0.0",
"@vueuse/math": "^14.1.0",
"@vueuse/nuxt": "^14.1.0",
"@vueuse/math": "^14.3.0",
"@vueuse/nuxt": "^14.3.0",
"codemirror": "^6.0.2",
"drizzle-orm": "^0.45.0",
"drizzle-orm": "^0.45.2",
"hast": "^1.0.0",
"hast-util-heading": "^3.0.0",
"hast-util-heading-rank": "^3.0.0",
@@ -32,9 +32,9 @@
"iconify-icon": "^3.0.2",
"lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.2",
"nodemailer": "^7.0.11",
"nuxt": "^4.2.2",
"nuxt-security": "^2.5.0",
"nodemailer": "^8.0.10",
"nuxt": "^4.4.8",
"nuxt-security": "^2.6.0",
"radix-vue": "^1.9.17",
"rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0",
@@ -43,22 +43,22 @@
"remark-ofm": "link:remark-ofm",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"rollup-plugin-postcss": "^4.0.2",
"tailwindcss": "^4.1.18",
"rollup-plugin-vue": "^6.0.0",
"strip-markdown": "^6.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vue": "^3.5.25",
"vue-router": "^4.6.3",
"zod": "^4.1.13"
"unist-util-visit": "^5.1.0",
"vue": "^3.5.35",
"vue-router": "^5.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/bun": "^1.3.4",
"@types/bun": "^1.3.14",
"@types/lodash.capitalize": "^4.2.9",
"@types/nodemailer": "^7.0.4",
"@types/nodemailer": "^8.0.0",
"@types/unist": "^3.0.3",
"bun-types": "^1.3.4",
"drizzle-kit": "^0.31.8",
"bun-types": "^1.3.14",
"drizzle-kit": "^0.31.10",
"mdast-util-to-string": "^4.0.0",
"rehype-stringify": "^10.0.1"
}

View File

@@ -1,4 +1,4 @@
import { hasPermissions } from "#shared/auth.util";
import { hasPermissions } from "#shared/auth";
declare module 'nitropack'
{

View File

@@ -1,6 +1,6 @@
import useDatabase from '~/composables/useDatabase';
import { projectFilesTable } from '~/db/schema';
import { hasPermissions } from '#shared/auth.util';
import { hasPermissions } from '~~/shared/auth';
export default defineEventHandler(async (e) => {
const session = await getUserSession(e);

View File

@@ -1,4 +1,4 @@
import { hasPermissions } from "#shared/auth.util";
import { hasPermissions } from "#shared/auth";
import useDatabase from '~/composables/useDatabase';
import { and, eq, notInArray } from "drizzle-orm";
import { z } from "zod";

View File

@@ -1,4 +1,4 @@
import { hasPermissions } from "#shared/auth.util";
import { hasPermissions } from "#shared/auth";
import useDatabase from '~/composables/useDatabase';
import { and, eq, notInArray } from "drizzle-orm";
import { z } from "zod";

View File

@@ -1,5 +1,5 @@
import useDatabase from '~/composables/useDatabase';
import { hasPermissions } from '#shared/auth.util';
import { hasPermissions } from '~~/shared/auth';
export default defineEventHandler(async (e) => {
const session = await getUserSession(e);

View File

@@ -2,8 +2,8 @@ import { eq } from 'drizzle-orm';
import { z } from 'zod/v4';
import useDatabase from '~/composables/useDatabase';
import { campaignTable } from '~/db/schema';
import { CampaignValidation } from '#shared/campaign.util';
import { cryptURI } from '#shared/general.util';
import { CampaignValidation } from '~~/shared/campaign';
import { cryptURI } from '~~/shared/general';
export default defineEventHandler(async (e) => {
const body = await readValidatedBody(e, CampaignValidation.extend({ id: z.unknown(), }).safeParse);

View File

@@ -1,7 +1,7 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { campaignTable } from '~/db/schema';
import { CampaignValidation } from '#shared/campaign.util';
import { CampaignValidation } from '~~/shared/campaign';
export default defineEventHandler(async (e) => {
const params = getRouterParam(e, "id");

View File

@@ -1,8 +1,8 @@
import { eq, SQL, type Operators } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable, userPermissionsTable } from '~/db/schema';
import { hasPermissions } from '#shared/auth.util';
import { group } from '#shared/general.util';
import { hasPermissions } from '~~/shared/auth';
import { group } from '~~/shared/general';
import type { Character, MainStat, TrainingLevel } from '~/types/character';
export default defineEventHandler(async (e) => {

View File

@@ -1,7 +1,7 @@
import { z } from 'zod/v4';
import useDatabase from '~/composables/useDatabase';
import { characterAbilitiesTable, characterLevelingTable, characterTable, characterTrainingTable } from '~/db/schema';
import { CharacterValidation } from '#shared/character.util';
import { CharacterValidation } from '~~/shared/character';
import { type Ability, type MainStat } from '~/types/character';

View File

@@ -1,6 +1,6 @@
import useDatabase from '~/composables/useDatabase';
import { campaignCharactersTable, campaignMembersTable, campaignTable, characterAbilitiesTable, characterChoicesTable, characterLevelingTable, characterTable, characterTrainingTable, usersTable } from '~/db/schema';
import { group } from '#shared/general.util';
import { group } from '~~/shared/general';
import type { Character, MainStat, TrainingLevel } from '~/types/character';
import { and, eq, exists, getTableColumns, isNotNull, or, sql } from 'drizzle-orm';

View File

@@ -1,7 +1,7 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterAbilitiesTable, characterChoicesTable, characterLevelingTable, characterTable, characterTrainingTable } from '~/db/schema';
import { CharacterValidation } from '#shared/character.util';
import { CharacterValidation } from '~~/shared/character';
import { type Ability, type MainStat } from '~/types/character';
export default defineEventHandler(async (e) => {

View File

@@ -1,7 +1,7 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema';
import { CharacterNotesValidation } from '#shared/character.util';
import { CharacterNotesValidation } from '~~/shared/character';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");

View File

@@ -1,8 +1,7 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema';
import { CharacterVariablesValidation } from '#shared/character.util';
import type { CharacterVariables } from '~/types/character';
import { CharacterVariablesValidation } from '~~/shared/character';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");

View File

@@ -1,5 +1,5 @@
import useDatabase from '~/composables/useDatabase';
import { hasPermissions } from '#shared/auth.util';
import { hasPermissions } from '~~/shared/auth';
import { projectContentTable, projectFilesTable } from '~/db/schema';
import { eq } from 'drizzle-orm';
@@ -14,7 +14,7 @@ export default defineEventHandler(async (e) => {
try
{
const id = getRouterParam(e, "id") ?? '';
const body = await readRawBody(e);
const body = Buffer.from(await readRawBody(e) ?? '');
if(!id)
{
@@ -32,7 +32,7 @@ export default defineEventHandler(async (e) => {
}
db.insert(projectContentTable).values({ id: id, content: body }).onConflictDoUpdate({ set: { content: body }, target: projectContentTable.id }).run();
db.update(projectFilesTable).set({ timestamp: new Date() }).where(eq(projectFilesTable.id, id)).run()
db.update(projectFilesTable).set({ timestamp: new Date() }).where(eq(projectFilesTable.id, id)).run();
}
catch(_e)
{

View File

@@ -1,7 +1,7 @@
import useDatabase from '~/composables/useDatabase';
import { hasPermissions } from "#shared/auth.util";
import { hasPermissions } from "#shared/auth";
import { eq, sql } from "drizzle-orm";
import { projectFilesTable } from "~/db/schema";
import { projectContentTable, projectFilesTable } from "~/db/schema";
import { Project } from "~/schemas/project";
export default defineEventHandler(async (e) => {
@@ -19,23 +19,24 @@ export default defineEventHandler(async (e) => {
throw body.error;
}
const db = useDatabase(), items = body.data, blocked: string[] = [];
const db = useDatabase(), items = body.data, requested: string[] = [];
db.transaction((tx) => {
const data = tx.select({ id: projectFilesTable.id, timestamp: projectFilesTable.timestamp }).from(projectFilesTable).all();
const deletion = tx.delete(projectFilesTable).where(eq(projectFilesTable.id, sql.placeholder('id'))).prepare();
const deleteOverview = tx.delete(projectFilesTable).where(eq(projectFilesTable.id, sql.placeholder('id'))).prepare();
const deleteContent = tx.delete(projectContentTable).where(eq(projectContentTable.id, sql.placeholder('id'))).prepare();
for(let i = 0; i < items.length; i++)
{
const item = items[i];
const submitted = items[i]!;
const index = data.findIndex(e => e.id === item.id);
const index = data.findIndex(e => e.id === submitted.id);
if(index !== -1)
{
if(data[index].timestamp > new Date(item.timestamp))
if(data[index]!.timestamp < new Date(submitted.timestamp))
{
blocked.push(item.id);
requested.push(submitted.id);
continue;
}
@@ -43,34 +44,36 @@ export default defineEventHandler(async (e) => {
}
tx.insert(projectFilesTable).values({
id: item.id,
path: item.path,
id: submitted.id,
path: submitted.path,
owner: user.id,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
title: submitted.title,
type: submitted.type,
navigable: submitted.navigable,
private: submitted.private,
order: submitted.order,
}).onConflictDoUpdate({
set: {
id: item.id,
path: item.path,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
id: submitted.id,
path: submitted.path,
title: submitted.title,
type: submitted.type,
navigable: submitted.navigable,
private: submitted.private,
order: submitted.order,
timestamp: new Date(),
},
target: projectFilesTable.id,
}).run();
}
//Delete the remaining data has they have not been found in the new overview
for(let i = 0; i < data.length; i++)
{
deletion.run({ id: data[i].id });
deleteOverview.run({ id: data[i]!.id });
deleteContent.run({ id: data[i]!.id });
}
});
return blocked;
return requested;
});

View File

@@ -1,4 +1,4 @@
import { dom, type VirtualNode } from "#shared/dom.virtual.util";
import { dom, type VirtualNode } from "#shared/dom.virtual";
export default function(content: VirtualNode[])
{

View File

@@ -1,4 +1,4 @@
import { dom, text } from "#shared/dom.virtual.util";
import { dom, text } from "#shared/dom.virtual";
export default function(data: any)
{

View File

@@ -1,5 +1,5 @@
import { dom, text } from "#shared/dom.virtual.util";
import { format } from "#shared/general.util";
import { dom, text } from "#shared/dom.virtual";
import { format } from "#shared/general";
export default function(data: any)
{

View File

@@ -1,7 +1,7 @@
import { and, eq, sql } from "drizzle-orm";
import useDatabase from "~/composables/useDatabase";
import { campaignMembersTable, campaignTable } from "~/db/schema";
import { decryptURI } from "#shared/general.util";
import { decryptURI } from "#shared/general";
export default defineEventHandler(async (e) => {
const link = getRouterParam(e, "link");

View File

@@ -1,4 +1,4 @@
import type { SocketMessage } from "#shared/websocket.util";
import type { SocketMessage } from "#shared/websocket";
export default defineWebSocketHandler({
message(peer, message) {

View File

@@ -1,8 +1,8 @@
import useDatabase from "~/composables/useDatabase";
import { extname, basename } from 'node:path';
import type { CanvasColor, CanvasContent } from "~/types/canvas";
import type { FileType, ProjectContent } from "#shared/content.util";
import { getID, parsePath } from "#shared/general.util";
import type { FileType, ProjectContent } from "#shared/content";
import { getID, parsePath } from "#shared/general";
import { projectContentTable, projectFilesTable } from "~/db/schema";
const typeMapping: Record<string, FileType> = {
@@ -71,10 +71,10 @@ export default defineTask({
const db = useDatabase();
db.transaction(tx => {
db.delete(projectFilesTable).run();
db.insert(projectFilesTable).values(files.map(e => {const { content, ...rest } = e; return rest; })).run();
db.delete(projectContentTable).run();
db.insert(projectContentTable).values(files.map(e => ({ content: e.content ? Buffer.from(e.content as string) : null, id: e.id }))).run();
tx.delete(projectFilesTable).run();
tx.insert(projectFilesTable).values(files.map(e => {const { content, ...rest } = e; return rest; })).run();
tx.delete(projectContentTable).run();
tx.insert(projectContentTable).values(files.map(e => ({ content: e.content ? Buffer.from(e.content as string) : null, id: e.id }))).run();
});
return { result: true };

View File

@@ -4,7 +4,7 @@ import { defu } from 'defu'
import { createHooks } from 'hookable'
import { useRuntimeConfig } from '#imports'
import type { UserSession, UserSessionRequired } from '~/types/auth'
import type { CompatEvent } from '~~/shared/websocket.util'
import type { CompatEvent } from '~~/shared/websocket'
export interface SessionHooks {
/**

34
shared/breakpoint.ts Normal file
View File

@@ -0,0 +1,34 @@
import { reactive } from '#shared/reactive';
export type Breakpoint = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
const BREAKPOINT_WIDTHS: [Breakpoint, number][] = [
['sm', 640],
['md', 768],
['lg', 1024],
['xl', 1280],
['2xl', 1536],
];
export const breakpoint = reactive({ container: 'lg' as Breakpoint, viewport: 'lg' as Breakpoint });
function updateBreakpoint()
{
for (let i = BREAKPOINT_WIDTHS.length - 1; i >= 0; i--)
{
if (window.innerWidth >= BREAKPOINT_WIDTHS[i]![1])
{
const bp = BREAKPOINT_WIDTHS[i]![0];
breakpoint.viewport = bp;
breakpoint.container = bp;
return;
}
}
}
if (typeof window !== 'undefined')
{
updateBreakpoint();
BREAKPOINT_WIDTHS.forEach(e => window.matchMedia(`(min-width: ${e[1]}px)`).addEventListener('change', updateBreakpoint));
}

View File

@@ -2,16 +2,16 @@ import { z } from "zod/v4";
import type { User } from "~/types/auth";
import characterConfig from '#shared/character-config.json';
import type { Campaign } from "~/types/campaign";
import { div, dom, icon, span, text, type RedrawableHTML } from "#shared/dom.util";
import { button, foldable, loading, numberpicker, tabgroup, Toaster } from "#shared/components.util";
import { CharacterCompiler, colorByRarity, stateFactory, subnameFactory } from "#shared/character.util";
import { modal, tooltip } from "#shared/floating.util";
import markdown from "#shared/markdown.util";
import { div, dom, icon, span, text } from "#shared/dom";
import { button, foldable, loading, numberpicker, tabgroup, Toaster } from "#shared/components";
import { CharacterCompiler, colorByRarity, stateFactory, subnameFactory } from "#shared/character";
import { modal, tooltip } from "#shared/floating";
import markdown from "#shared/markdown";
import { preview } from "#shared/proses";
import { Socket } from "#shared/websocket.util";
import { Socket } from "#shared/websocket";
import { reactive } from "#shared/reactive";
import type { Character, CharacterConfig } from "~/types/character";
import { MarkdownEditor } from "./editor.util";
import { MarkdownEditor } from "./editor";
import { getText } from "./i18n";
const config = characterConfig as CharacterConfig;
@@ -27,7 +27,7 @@ export const CampaignValidation = z.object({
class CharacterPrinter
{
compiler?: CharacterCompiler;
container: RedrawableHTML;
container: HTMLElement;
name: string;
id: number;
constructor(character: number, name: string)
@@ -64,7 +64,7 @@ export class CampaignSheet
private campaign?: Campaign;
private characters!: Array<CharacterPrinter>;
container: RedrawableHTML = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6');
container: HTMLElement = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6');
ws?: Socket;
constructor(id: string, user: ComputedRef<User | null>)
@@ -245,16 +245,16 @@ export class CampaignSheet
const items = this.campaign.items;
const money = {
readonly: dom('div', { listeners: { click: () => { money.readonly.replaceWith(money.edit); money.edit.focus(); } }, class: 'cursor-pointer border border-transparent hover:border-light-40 dark:hover:border-dark-40 px-2 py-px flex flex-row gap-1 items-center' }, [ span('text-lg font-bold', () => this.campaign!.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]),
/* const money = {
readonly: dom('div', { listeners: { click: () => { money.readonly.replaceWith(money.edit); money.edit.focus(); } }, class: 'cursor-pointer border border-transparent hover:border-light-40 hover:dark:border-dark-40 px-2 py-px flex flex-row gap-1 items-center' }, [ span('text-lg font-bold', () => this.campaign!.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]),
edit: numberpicker({ defaultValue: this.campaign.money, change: v => { this.campaign!.money = v; this.saveVariables(); money.edit.replaceWith(money.readonly); }, blur: v => { this.campaign!.money = v; this.saveVariables(); money.edit.replaceWith(money.readonly); }, min: 0, class: 'w-24' }),
};
}; */
return [
div('flex flex-col gap-2', [
div('flex flex-row justify-between items-center', [
div('flex flex-row justify-end items-center gap-8', [
div('flex flex-row gap-1 items-center', [ span('italic text-sm', 'Argent'), this.user.value && this.user.value.id === this.campaign.owner.id ? money.readonly : div('cursor-pointer px-2 py-px flex flex-row gap-1 items-center', [ span('text-lg font-bold', () => this.campaign!.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]) ]),
div('flex flex-row gap-1 items-center', [ span('italic text-sm', 'Argent'), /* this.user.value && this.user.value.id === this.campaign.owner.id ? money.readonly : div('cursor-pointer px-2 py-px flex flex-row gap-1 items-center', [ span('text-lg font-bold', () => this.campaign!.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]) */text("TODO") ]),
])
]),
div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: this.campaign.items, render: (e, _c) => {
@@ -264,7 +264,7 @@ export class CampaignSheet
if(!item) return;
const itempower = () => (item.powercost ?? 0) + (e.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0);
const itempower = () => (item.powercost ?? 0) + (e.improvements?.reduce((_p, _v) => (config.improvements[_v]?.power ?? 0) + _p, 0) ?? 0);
const price = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.price }], [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.price }), () => item.price ? `${item.price * e.amount}` : '-') ]);
const weight = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.weight }], [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.weight }), () => item.weight ? `${item.weight * e.amount}` : '-') ]);

View File

@@ -1,11 +1,11 @@
import type { CanvasContent, CanvasEdge, CanvasNode } from "~/types/canvas";
import { clamp, lerp } from "#shared/general.util";
import { dom, icon, svg, type RedrawableHTML } from "#shared/dom.util";
import render from "#shared/markdown.util";
import { tooltip } from "#shared/floating.util";
import { History } from "#shared/history.util";
import { clamp, lerp } from "#shared/general";
import { dom, icon, svg } from "#shared/dom";
import render from "#shared/markdown";
import { tooltip } from "#shared/floating";
import { History } from "#shared/history";
import { preview } from "#shared/proses";
import { SpatialGrid } from "#shared/physics.util";
import { SpatialGrid } from "#shared/physics";
import type { CanvasPreferences } from "~/types/general";
/*
@@ -200,7 +200,7 @@ export class Node extends EventTarget
{
properties: CanvasNode;
nodeDom?: RedrawableHTML;
nodeDom?: HTMLElement;
constructor(properties: CanvasNode)
{
@@ -214,8 +214,8 @@ export class Node extends EventTarget
const style = this.style;
this.nodeDom = dom('div', { class: ['absolute', {'-z-10': this.properties.type === 'group', 'z-10': this.properties.type !== 'group'}], style: { transform: `translate(${this.properties.x}px, ${this.properties.y}px)`, width: `${this.properties.width}px`, height: `${this.properties.height}px`, '--canvas-color': this.properties.color?.hex } }, [
dom('div', { class: ['outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4', style.border] }, [
dom('div', { class: ['w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto', style.bg] }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text)]) : undefined])
dom('div', { class: ['outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full', style.border] }, [
dom('div', { class: ['w-full h-full py-2 px-4 flex overflow-auto', style.bg] }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text)]) : undefined])
])
]);
@@ -238,9 +238,9 @@ export class Node extends EventTarget
get style()
{
return this.properties.color ? this.properties.color?.class ?
{ bg: `bg-light-${this.properties.color?.class} dark:bg-dark-${this.properties.color?.class}`, border: `border-light-${this.properties.color?.class} dark:border-dark-${this.properties.color?.class}` } :
{ bg: `bg-light-${this.properties.color?.class}/10 dark:bg-dark-${this.properties.color?.class}/10`, border: `border-light-${this.properties.color?.class} dark:border-dark-${this.properties.color?.class}` } :
{ bg: `bg-colored`, border: `border-[color:var(--canvas-color)]` } :
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40` }
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40/10 dark:bg-dark-40/10` }
}
}
export class NodeEditable extends Node
@@ -258,7 +258,7 @@ export class NodeEditable extends Node
this.nodeDom = dom('div', { class: ['absolute group', {'-z-10': this.properties.type === 'group', 'z-10': this.properties.type !== 'group'}], style: { transform: `translate(${this.properties.x}px, ${this.properties.y}px)`, width: `${this.properties.width}px`, height: `${this.properties.height}px`, '--canvas-color': this.properties.color?.hex } }, [
dom('div', { class: ['outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full group-hover:outline-4', style.border, style.outline] }, [
dom('div', { class: ['w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto', style.bg], listeners: this.properties.type === 'text' ? { mouseenter: e => this.dispatchEvent(new CustomEvent('focus', { detail: this })), click: e => this.dispatchEvent(new CustomEvent('select', { detail: this })), dblclick: e => this.dispatchEvent(new CustomEvent('edit', { detail: this })) } : undefined }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text, undefined, { tags: { a: preview } })]) : undefined])
dom('div', { class: ['w-full h-full py-2 px-4 flex overflow-auto', style.bg], listeners: this.properties.type === 'text' ? { mouseenter: e => this.dispatchEvent(new CustomEvent('focus', { detail: this })), click: e => this.dispatchEvent(new CustomEvent('select', { detail: this })), dblclick: e => this.dispatchEvent(new CustomEvent('edit', { detail: this })) } : undefined }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text, undefined, { tags: { a: preview } })]) : undefined])
])
]);
@@ -287,9 +287,9 @@ export class NodeEditable extends Node
override get style()
{
return this.properties.color ? this.properties.color?.class ?
{ bg: `bg-light-${this.properties.color?.class} dark:bg-dark-${this.properties.color?.class}`, border: `border-light-${this.properties.color?.class} dark:border-dark-${this.properties.color?.class}`, outline: `outline-light-${this.properties.color?.class} dark:outline-dark-${this.properties.color?.class}` } :
{ bg: `bg-light-${this.properties.color?.class}/10 dark:bg-dark-${this.properties.color?.class}/10`, border: `border-light-${this.properties.color?.class} dark:border-dark-${this.properties.color?.class}`, outline: `outline-light-${this.properties.color?.class} dark:outline-dark-${this.properties.color?.class}` } :
{ bg: `bg-colored`, border: `border-[color:var(--canvas-color)]`, outline: `outline-[color:var(--canvas-color)]` } :
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40`, outline: `outline-light-40 dark:outline-dark-40` }
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40/10 dark:bg-dark-40/10`, outline: `outline-light-40 dark:outline-dark-40` }
}
get x()
@@ -334,7 +334,7 @@ export class Edge extends EventTarget
{
properties: CanvasEdge;
edgeDom?: RedrawableHTML;
edgeDom?: HTMLElement;
protected from: Node;
protected to: Node;
protected path: Path;
@@ -388,7 +388,7 @@ export class EdgeEditable extends Edge
private editing: boolean = false;
private pathDom?: SVGPathElement;
private inputDom?: RedrawableHTML;
private inputDom?: HTMLElement;
constructor(properties: CanvasEdge, from: NodeEditable, to: NodeEditable)
{
super(properties, from, to);
@@ -441,8 +441,8 @@ export class Canvas
protected tweener: Tweener = new Tweener();
private debouncedTimeout: Timer = setTimeout(() => {}, 0);
protected transform!: RedrawableHTML;
container!: RedrawableHTML;
protected transform!: HTMLElement;
container!: HTMLElement;
protected firstX = 0;
protected firstY = 0;
@@ -486,9 +486,9 @@ export class Canvas
this.container = dom('div', { class: 'absolute top-0 left-0 overflow-hidden w-full h-full touch-none' }, [
dom('div', { class: 'flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4' }, [
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom * 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:plus')]), 'Zoom avant', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: (e: MouseEvent) => { this.reset(); } } }, [icon('radix-icons:corners')]), 'Tout contenir', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom / 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:minus')]), 'Zoom arrière', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 hover:dark:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom * 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:plus')]), 'Zoom avant', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 hover:dark:bg-dark-30 cursor-pointer', listeners: { click: (e: MouseEvent) => { this.reset(); } } }, [icon('radix-icons:corners')]), 'Tout contenir', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 hover:dark:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom / 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:minus')]), 'Zoom arrière', 'right'),
]),
]), this.transform,
]);
@@ -780,17 +780,17 @@ export class CanvasEditor extends Canvas
this.container = dom('div', { class: 'absolute top-0 left-0 overflow-hidden w-full h-full touch-none', listeners: { mousedown: () => { this.selection.clear(); this.updateSelection() } } }, [
dom('div', { class: 'flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4' }, [
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom * 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:plus')]), 'Zoom avant', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.reset() } }, [icon('radix-icons:corners')]), 'Tout contenir', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom / 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:minus')]), 'Zoom arrière', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 hover:dark:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom * 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:plus')]), 'Zoom avant', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 hover:dark:bg-dark-30 cursor-pointer', listeners: { click: () => this.reset() } }, [icon('radix-icons:corners')]), 'Tout contenir', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 hover:dark:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom / 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:minus')]), 'Zoom arrière', 'right'),
]),
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.history.undo() } }, [icon('ph:arrow-bend-up-left')]), 'Annuler (Ctrl+Z)', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.history.redo() } }, [icon('ph:arrow-bend-up-right')]), 'Rétablir (Ctrl+Y)', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 hover:dark:bg-dark-30 cursor-pointer', listeners: { click: () => this.history.undo() } }, [icon('ph:arrow-bend-up-left')]), 'Annuler (Ctrl+Z)', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 hover:dark:bg-dark-30 cursor-pointer', listeners: { click: () => this.history.redo() } }, [icon('ph:arrow-bend-up-right')]), 'Rétablir (Ctrl+Y)', 'right'),
]),
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => {} } }, [icon('radix-icons:gear')]), 'Préférences', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => {} } }, [icon('radix-icons:question-mark-circled')]), 'Aide', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 hover:dark:bg-dark-30 cursor-pointer', listeners: { click: () => {} } }, [icon('radix-icons:gear')]), 'Préférences', 'right'),
tooltip(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 hover:dark:bg-dark-30 cursor-pointer', listeners: { click: () => {} } }, [icon('radix-icons:question-mark-circled')]), 'Aide', 'right'),
]),
]), this.pattern, this.transform
]);

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,16 @@
import type { RouteLocationAsRelativeTyped, RouteLocationRaw, RouteMapGeneric } from "vue-router";
import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node, type RedrawableHTML } from "#shared/dom.util";
import { contextmenu, followermenu, minimizeBox, popper, teleport, tooltip, type FloatState } from "#shared/floating.util";
import { clamp } from "#shared/general.util";
import { type NodeProperties, type Class, type NodeChildren, dom, text, div, icon, type Node, span } from "#shared/dom";
import { followermenu, minimizeBox, popper, teleport, tooltip, type FloatState } from "#shared/floating";
import { clamp, shallowEquals } from "#shared/general";
import { Tree } from "#shared/tree";
import type { Placement } from "@floating-ui/dom";
import { type Reactive } from '#shared/reactive';
import { reactivity, type Reactive, reactive } from '#shared/reactive';
export function link(children: NodeChildren, properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped<RouteMapGeneric, string>)
{
const router = useRouter();
const nav = link ? router.resolve(link) : undefined;
return dom('a', { ...properties, class: [properties?.class, properties?.active && router.currentRoute.value.fullPath === nav?.fullPath ? properties.active : undefined], attributes: { href: nav?.href, 'data-active': properties?.active ? mergeClasses(properties?.active) : undefined }, listeners: link ? {
return dom('a', { ...properties, class: [properties?.class, properties?.active && router.currentRoute.value.fullPath === nav?.fullPath ? properties.active : undefined], attributes: { href: nav?.href, 'data-active': properties?.active }, listeners: link ? {
click: function(e)
{
e.preventDefault();
@@ -18,11 +18,11 @@ export function link(children: NodeChildren, properties?: NodeProperties & { act
}
} : undefined }, children);
}
export function loading(size: 'small' | 'normal' | 'large' = 'normal'): RedrawableHTML
export function loading(size: 'small' | 'normal' | 'large' = 'normal'): HTMLElement
{
return dom('span', { class: ["after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin", {'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}] })
}
export function async(size: 'small' | 'normal' | 'large' = 'normal', fn: Promise<RedrawableHTML>)
export function async(size: 'small' | 'normal' | 'large' = 'normal', fn: Promise<HTMLElement>)
{
let state = { current: loading(size) };
@@ -36,30 +36,32 @@ export function async(size: 'small' | 'normal' | 'large' = 'normal', fn: Promise
return state;
}
export function button(content: Node | NodeChildren, onClick?: (this: RedrawableHTML) => void, cls?: Class)
export function button(content: Node | NodeChildren, onClick?: (this: HTMLElement) => void, cls?: Class, disabled?: Reactive<boolean>)
{
const btn = dom('button', { class: [`inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
const btn = dom('button', { class: [cls, `inline-flex justify-center items-center outline-hidden leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50
disabled:text-light-50 dark:disabled:text-dark-50 disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-dashed disabled:border-light-40 dark:disabled:border-dark-40`, cls], listeners: { click: () => disabled || (onClick && onClick.bind(btn)()) } }, Array.isArray(content) ? content : [content]);
let disabled = false;
hover:bg-light-25 hover:dark:bg-dark-25 hover:border-light-50 hover:dark:border-dark-50
focus:bg-light-30 focus:dark:bg-dark-30 focus:border-light-50 focus:dark:border-dark-50 focus:shadow-raw focus:shadow-light-50 focus:dark:shadow-dark-50
disabled:text-light-50 dark:disabled:text-dark-50 disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-dashed disabled:border-light-40 dark:disabled:border-dark-40`], listeners: { click: () => _disabled || (onClick && onClick.bind(btn)()) } }, Array.isArray(content) ? content : [content]);
let _disabled = false;
Object.defineProperty(btn, 'disabled', {
get: () => disabled,
get: () => _disabled,
set: (v) => {
disabled = !!v;
btn.toggleAttribute('disabled', disabled);
_disabled = !!v;
btn.toggleAttribute('disabled', _disabled);
}
})
});
disabled && reactivity(disabled, (d) => {
btn.disabled = d;
});
return btn;
}
export function buttongroup<T extends any>(options: Array<{ text: string, value: T }>, settings?: { class?: { container?: Class, option?: Class }, value?: T, onChange?: (value: T) => boolean | void })
{
let currentValue = settings?.value;
const elements = options.map(e => dom('div', { class: [`cursor-pointer text-light-100 dark:text-dark-100 hover:bg-light-30 dark:hover:bg-dark-30 flex items-center justify-center bg-light-20 dark:bg-dark-20 leading-none outline-none
border border-light-40 dark:border-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[selected]:z-10 data-[selected]:border-light-50 dark:data-[selected]:border-dark-50
data-[selected]:shadow-raw transition-[box-shadow] data-[selected]:shadow-light-50 dark:data-[selected]:shadow-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40`,
settings?.class?.option], text: e.text, attributes: { 'data-selected': settings?.value === e.value }, listeners: { click: function() {
const elements = options.map(e => dom('div', { class: [settings?.class?.option, `cursor-pointer text-light-100 dark:text-dark-100 hover:bg-light-30 hover:dark:bg-dark-30 flex items-center justify-center bg-light-20 dark:bg-dark-20 leading-none outline-hidden
border border-light-40 dark:border-dark-40 hover:border-light-50 hover:dark:border-dark-50 data-[selected]:z-10 data-[selected]:border-light-50 dark:data-[selected]:border-dark-50
data-[selected]:shadow-raw transition-[box-shadow] data-[selected]:shadow-light-50 dark:data-[selected]:shadow-dark-50 focus:shadow-raw focus:shadow-light-40 focus:dark:shadow-dark-40`], text: e.text, attributes: { 'data-selected': settings?.value === e.value }, listeners: { click: function() {
if(currentValue !== e.value)
{
elements.forEach(e => e.toggleAttribute('data-selected', false));
@@ -71,24 +73,24 @@ export function buttongroup<T extends any>(options: Array<{ text: string, value:
}
}
}}}))
return div(['flex flex-row', settings?.class?.container], elements);
return div([settings?.class?.container, 'flex flex-row'], elements);
}
export function optionmenu(options: Array<{ title: string, click: () => void }>, settings?: { position?: Placement, class?: { container?: Class, option?: Class } }): (target?: RedrawableHTML) => void
export function optionmenu(options: Array<{ title: string, click: () => void }>, settings?: { position?: Placement, class?: { container?: Class, option?: Class } }): (target?: HTMLElement) => void
{
let close: () => void;
const element = div(['flex flex-col divide-y divide-light-30 dark:divide-dark-30 text-light-100 dark:text-dark-100', settings?.class?.container], options.map(e => dom('div', { class: ['flex flex-row px-2 py-1 hover:bg-light-35 dark:hover:bg-dark-35 cursor-pointer', settings?.class?.option], text: e.title, listeners: { click: () => { e.click(); close() } } })));
return function(this: RedrawableHTML, target?: RedrawableHTML) {
const element = div([settings?.class?.container, 'flex flex-col divide-y divide-light-30 dark:divide-dark-30 text-light-100 dark:text-dark-100'], options.map(e => dom('div', { class: [settings?.class?.option, 'flex flex-row px-2 py-1 hover:bg-light-35 hover:dark:bg-dark-35 cursor-pointer'], text: e.title, listeners: { click: () => { e.click(); close() } } })));
return function(this: HTMLElement, target?: HTMLElement) {
close = followermenu(target ?? this, [ element ], { arrow: true, placement: settings?.position, offset: 8 }).close;
}
}
export type Option<T> = { text: string, render?: () => RedrawableHTML, value: T | Option<T>[] } | undefined;
type StoredOption<T> = { item: Option<T>, dom: RedrawableHTML, container?: RedrawableHTML, children?: Array<StoredOption<T>> };
export function select<T extends NonNullable<any>>(options: Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): RedrawableHTML
export type Option<T> = { text: string, value: T, render?: () => HTMLElement } | undefined;
export type RecurrentOption<T> = { text: string, render?: () => HTMLElement, value: T | RecurrentOption<T>[] } | undefined;
type StoredRecurrentOption<T> = { item: RecurrentOption<T>, dom: HTMLElement, container?: HTMLElement, children?: Array<StoredRecurrentOption<T>> };
type StoredOption<T> = { item: Option<T>, dom: HTMLElement, container?: HTMLElement };
export function select<T extends NonNullable<any>>(options: Reactive<Array<{ text: string, value: T } | undefined>>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLDivElement & { disabled: boolean, value: T | undefined }
{
let context: { close: Function };
let focused: number | undefined;
options = options.filter(e => !!e);
let context: { close: Function }, _options: Array<{ text: string, value: T }> = [], optionElements: HTMLElement[] = [];
let focused: number | undefined, value: T | undefined, valueText: Text = text(''), disabled: boolean, change: ((v: T) => void) | undefined = undefined;
const focus = (i?: number) => {
focused !== undefined && optionElements[focused]?.toggleAttribute('data-focused', false);
@@ -96,51 +98,36 @@ export function select<T extends NonNullable<any>>(options: Array<{ text: string
focused = i;
}
let disabled = settings?.disabled ?? false;
const textValue = text(options.find(e => Array.isArray(e) ? false : e?.value === settings?.defaultValue)?.text ?? '');
const optionElements = options.map((e, i) => {
if(e === undefined)
return;
return dom('div', { listeners: { click: (_e) => {
textValue.textContent = e.text;
settings?.change && settings?.change(e.value);
context && context.close && !_e.ctrlKey && context.close();
}, mouseenter: (e) => focus(i) }, class: ['data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ text(e.text) ]);
});
const select = dom('div', { listeners: { click: () => {
if(disabled)
return;
const handleKeys = (e: KeyboardEvent) => {
switch(e.key.toLocaleLowerCase())
{
case 'arrowdown':
focus(clamp((focused ?? -1) + 1, 0, options.length - 1));
return;
case 'arrowup':
focus(clamp((focused ?? 1) - 1, 0, options.length - 1));
return;
case 'pageup':
focus(0);
return;
case 'pagedown':
focus(optionElements.length - 1);
return;
case 'enter':
focused && optionElements[focused]?.click();
return;
case 'escape':
context?.close();
return;
default: return;
}
const handleKeys = (e: KeyboardEvent) => {
switch(e.key.toLocaleLowerCase())
{
case 'arrowdown':
focus(clamp((focused ?? -1) + 1, 0, _options.length - 1));
return;
case 'arrowup':
focus(clamp((focused ?? 1) - 1, 0, _options.length - 1));
return;
case 'pageup':
focus(0);
return;
case 'pagedown':
focus(optionElements.length - 1);
return;
case 'enter':
focused && optionElements[focused]?.click();
return;
case 'escape':
context?.close();
return;
default: return;
}
window.addEventListener('keydown', handleKeys);
}
const select = dom('div', { listeners: { focus: () => {
if(disabled) return;
const box = select.getBoundingClientRect();
context = 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` }, blur: () => window.removeEventListener('keydown', handleKeys) });
} }, 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') ]);
window.addEventListener('keydown', handleKeys);
context = followermenu(select, 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: [settings?.class?.popup, 'flex flex-col max-h-[320px] overflow-auto'], style: { 'min-width': `${select.clientWidth}px` }, blur: () => window.removeEventListener('keydown', handleKeys) });
} }, class: [settings?.class?.container, 'mx-4 inline-flex items-center justify-between px-3 text-sm font-semibold leading-none outline-hidden cursor-default 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 focus:dark:shadow-dark-40 hover:border-light-50 hover:dark: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'], attributes: { tabindex: '0' } }, [ span('', valueText), icon('radix-icons:caret-down') ]) as HTMLDivElement & { value: T | undefined, disabled: boolean };
Object.defineProperty(select, 'disabled', {
get: () => disabled,
@@ -148,12 +135,131 @@ export function select<T extends NonNullable<any>>(options: Array<{ text: string
disabled = !!v;
select.toggleAttribute('data-disabled', disabled);
},
})
});
Object.defineProperty(select, 'value', {
get: () => value,
set: (v) => {
if(v === value) return;
if(v === undefined)
{
valueText.textContent = '';
focus();
}
else
{
const idx = _options.findIndex(e => e?.value === v);
if(idx !== -1)
{
valueText.textContent = _options[idx]?.text ?? '';
focus(idx);
}
else return select.value = undefined;
}
value = v;
change && change(v);
}
});
reactivity(options, (o) => {
_options = o.filter(e => !!e);
if(!_options.find(e => e.value === value)) select.value = undefined;
optionElements = _options.map((e, i) => dom('div', { listeners: { click: (_e) => { select.value = e.value ; context.close(); }, mouseenter: (e) => focus(i) }, class: [settings?.class?.option, 'data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer'], text: e.text }));
});
select.disabled = settings?.disabled ?? false;
select.value = settings?.defaultValue;
change = settings?.change;
return select;
}
export function multiselect<T extends NonNullable<any>>(options: Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T[], change?: (value: T[]) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): RedrawableHTML
export function multiselect<T extends NonNullable<any>>(options: Option<T>[], settings?: { defaultValue?: T[], change?: (value: T[]) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLDivElement & { disabled: boolean, value: T[] | undefined }
{
let context: { close: Function };
let context: { close: Function }, _options: Array<{ text: string, value: T }> = [], optionElements: HTMLElement[] = [];
let focused: number | undefined, value: T[] | undefined, valueText: Text = text(''), disabled: boolean, change: ((v: T[]) => void) | undefined = undefined;
const focus = (i?: number) => {
focused !== undefined && optionElements[focused]?.toggleAttribute('data-focused', false);
i !== undefined && optionElements[i]?.toggleAttribute('data-focused', true) && optionElements[i]?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
focused = i;
}
const handleKeys = (e: KeyboardEvent) => {
switch(e.key.toLocaleLowerCase())
{
case 'arrowdown':
focus(clamp((focused ?? -1) + 1, 0, _options.length - 1));
return;
case 'arrowup':
focus(clamp((focused ?? 1) - 1, 0, _options.length - 1));
return;
case 'pageup':
focus(0);
return;
case 'pagedown':
focus(optionElements.length - 1);
return;
case 'enter':
focused && optionElements[focused]?.click();
return;
case 'escape':
context?.close();
return;
default: return;
}
}
const select = dom('div', { listeners: { focus: () => {
if(disabled) return;
window.addEventListener('keydown', handleKeys);
context = followermenu(select, 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: [settings?.class?.popup, 'flex flex-col max-h-[320px] overflow-auto'], style: { 'min-width': `${select.clientWidth}px` }, blur: () => window.removeEventListener('keydown', handleKeys) });
} }, class: [settings?.class?.container, 'mx-4 inline-flex items-center justify-between px-3 text-sm font-semibold leading-none outline-hidden cursor-default 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 focus:dark:shadow-dark-40 hover:border-light-50 hover:dark: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'], attributes: { tabindex: '0' } }, [ span('', valueText), icon('radix-icons:caret-down') ]) as HTMLDivElement & { value: T[] | undefined, disabled: boolean };
Object.defineProperty(select, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
select.toggleAttribute('data-disabled', disabled);
},
});
Object.defineProperty(select, 'value', {
get: () => value,
set: (v) => {
if(Array.isArray(v)) v = v.filter(e => _options.find(f => f.value === e));
if(shallowEquals(v, value)) return;
if(v === undefined || (Array.isArray(v) && v.length === 0))
{
valueText.textContent = '';
focus();
}
else if(Array.isArray(v)) valueText.textContent = `${_options.find(e => e.value === v[0])?.text ?? ''}${v.length > 1 ? ' +' + (v.length - 1) : ''}`;
else throw new Error('Invalid value type');
value = v;
change && change(v);
}
});
reactivity(options, (o) => {
_options = o.filter(e => !!e);
if(!_options.find(e => e.value === value)) select.value = undefined;
optionElements = _options.map((e, i) => dom('div', { listeners: { click: function(_e) {
const v = [];
if(select.value) v.push(...select.value);
const idx = select.value?.indexOf(e.value) ?? -1;
idx === -1 ? v.push(e.value) : v.splice(idx, 1);
this.toggleAttribute('data-selected', idx === -1);
select.value = v;
_e.ctrlKey || context.close();
}, mouseenter: (e) => focus(i) }, class: [settings?.class?.option, 'group flex flex-row justify-between items-center data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer'], attributes: { 'data-selected': settings?.defaultValue?.includes(e.value) ?? false } }, [ text(e.text), icon('radix-icons:check', { class: 'hidden group-data-[selected]:block' }) ]));
});
select.disabled = settings?.disabled ?? false;
select.value = settings?.defaultValue;
change = settings?.change;
return select;
/* let context: { close: Function };
let focused: number | undefined;
let selection: T[] = settings?.defaultValue ?? [];
@@ -212,7 +318,7 @@ export function multiselect<T extends NonNullable<any>>(options: Array<{ text: s
const box = select.getBoundingClientRect();
context = 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` }, blur: () => window.removeEventListener('keydown', handleKeys) });
} }, 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') ]);
} }, 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 focus:dark:shadow-dark-40 hover:border-light-50 hover:dark: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,
@@ -221,16 +327,16 @@ export function multiselect<T extends NonNullable<any>>(options: Array<{ text: s
select.toggleAttribute('data-disabled', disabled);
},
})
return select;
return select; */
}
export function combobox<T extends NonNullable<any>>(options: Option<T>[], settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' })
export function combobox<T extends NonNullable<any>>(options: RecurrentOption<T>[], settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' })
{
let context: { container: RedrawableHTML, content: NodeChildren, close: () => void };
let selected = true, tree: StoredOption<T>[] = [];
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
let selected = true, tree: StoredRecurrentOption<T>[] = [];
let focused: number | undefined;
let currentOptions: StoredOption<T>[] = [];
let currentOptions: StoredRecurrentOption<T>[] = [];
const focus = (value?: T | Option<T>[]) => {
const focus = (value?: T | RecurrentOption<T>[]) => {
focused !== undefined && currentOptions[focused]?.dom.toggleAttribute('data-focused', false);
if(value !== undefined)
{
@@ -257,7 +363,7 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
const box = container.getBoundingClientRect();
focus();
context = followermenu(container, currentOptions.length > 0 ? currentOptions.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-y-auto overflow-x-hidden', settings?.class?.popup], style: { "min-width": settings?.fill === 'cover' && `${box.width}px`, "max-width": settings?.fill === 'contain' && `${box.width}px` }, blur: hide });
context = followermenu(container, currentOptions.length > 0 ? currentOptions.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: [settings?.class?.popup, 'flex flex-col max-h-[320px] overflow-y-auto overflow-x-hidden'], style: { "min-width": settings?.fill === 'cover' && `${box.width}px`, "max-width": settings?.fill === 'contain' && `${box.width}px` }, blur: hide });
if(!selected) container.classList.remove('!border-light-red', 'dark:!border-dark-red');
};
const hide = () => {
@@ -266,7 +372,7 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
context && context.container.parentElement && context.close();
};
const progress = (option: StoredOption<T>) => {
const progress = (option: StoredRecurrentOption<T>) => {
if(!context || !context.container.parentElement || option.container === undefined)
return;
@@ -282,7 +388,7 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
last ? context.container.replaceChildren(last.container ?? last.dom) : context.container.replaceChildren(...optionElements.filter(e => !!e).map(e => e.dom));
};
const render = (option: Option<T>): StoredOption<T> | undefined => {
const render = (option: RecurrentOption<T>): StoredRecurrentOption<T> | undefined => {
if(option === undefined)
return;
@@ -290,15 +396,15 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
{
const children = option.value.map(render).filter(e => !!e);
const stored = { item: option, dom: dom('div', { listeners: { click: () => progress(stored), mouseenter: () => focus(option.value) }, class: ['data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer flex justify-between items-center', settings?.class?.option] }, [ text(option.text), icon('radix-icons:caret-right', { width: 20, height: 20 }) ]), container: div('flex flex-1 flex-col', [div('flex flex-row justify-between items-center text-light-100 dark:text-dark-100 py-1 px-2 text-sm select-none sticky top-0 bg-light-20 dark:bg-dark-20 font-semibold', [button(icon('radix-icons:caret-left', { width: 16, height: 16 }), back, 'p-px'), text(option.text), div()]), div('flex flex-col flex-1', children.map(e => e?.dom))]), children };
const stored = { item: option, dom: dom('div', { listeners: { click: () => progress(stored), mouseenter: () => focus(option.value) }, class: [settings?.class?.option, 'data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer flex justify-between items-center'] }, [ text(option.text), icon('radix-icons:caret-right', { width: 20, height: 20 }) ]), container: div('flex flex-1 flex-col', [div('flex flex-row justify-between items-center text-light-100 dark:text-dark-100 py-1 px-2 text-sm select-none sticky top-0 bg-light-20 dark:bg-dark-20 font-semibold', [button(icon('radix-icons:caret-left', { width: 16, height: 16 }), back, 'p-px'), text(option.text), div()]), div('flex flex-col flex-1', children.map(e => e?.dom))]), children };
return stored;
}
else
{
return { item: option, dom: dom('div', { listeners: { click: (_e) => { container.value = option.value as T; !_e.ctrlKey && hide(); }, mouseenter: () => focus(option.value) }, class: ['data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ option?.render ? option?.render() : text(option.text) ]) };
return { item: option, dom: dom('div', { listeners: { click: (_e) => { container.value = option.value as T; !_e.ctrlKey && hide(); }, mouseenter: () => focus(option.value) }, class: [settings?.class?.option, 'data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer'] }, [ option?.render ? option?.render() : text(option.text) ]) };
}
}
const filter = (value: string, option?: StoredOption<T>): StoredOption<T>[] => {
const filter = (value: string, option?: StoredRecurrentOption<T>): StoredRecurrentOption<T>[] => {
if(option && option.children !== undefined)
{
return option.children.flatMap(e => filter(value, e));
@@ -344,9 +450,9 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
return;
default: return;
}
} }, attributes: { type: 'text', }, class: 'flex-1 outline-none px-3 leading-none appearance-none py-1 bg-light-25 dark:bg-dark-25 disabled:bg-light-20 dark:disabled:bg-dark-20 w-full' });
} }, attributes: { type: 'text', }, class: 'flex-1 outline-hidden px-3 leading-none appearance-none py-1 bg-light-25 dark:bg-dark-25 disabled:bg-light-20 dark:disabled:bg-dark-20 w-full' });
const container = dom('label', { class: ['inline-flex outline-none px-3 items-center justify-between text-sm font-semibold leading-none gap-1 bg-light-25 dark:bg-dark-25 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark:data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ select, icon('radix-icons:caret-down') ]) as HTMLLabelElement & { disabled: boolean, value: T | undefined };
const container = dom('label', { class: [settings?.class?.container, 'inline-flex outline-hidden 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 focus:dark:shadow-dark-40 hover:border-light-50 hover:dark: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'] }, [ select, icon('radix-icons:caret-down') ]) as HTMLLabelElement & { disabled: boolean, value: T | undefined };
let value: T | undefined = undefined;
Object.defineProperty(container, 'disabled', {
@@ -379,11 +485,126 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
}
return container;
}
export function tagpicker<T extends NonNullable<any>>(options: Reactive<Option<T>[]>, settings?: { defaultValue?: T[], change?: (value: T[]) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' })
{
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
let focused: number | undefined;
let currentOptions: StoredOption<T>[] = [];
const focus = (value?: T | Option<T>[]) => {
focused !== undefined && currentOptions[focused]?.dom.toggleAttribute('data-focused', false);
if(value !== undefined)
{
const i = currentOptions.findIndex(e => e.item?.value === value);
if(i !== -1)
{
currentOptions[i]?.dom.toggleAttribute('data-focused', true);
currentOptions[i]?.dom.scrollIntoView({ behavior: 'instant', block: 'nearest' });
focused = i;
}
else
{
focused = undefined;
}
}
else
{
focused = undefined;
}
}
const show = () => {
if(disabled || (context && context.container.parentElement))
return;
const box = container.getBoundingClientRect();
focus();
currentOptions = optionElements.map(e => filter(select.value.toLowerCase().trim().normalize(), e)).filter(e => !!e);
context = followermenu(container, currentOptions.length > 0 ? currentOptions.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: [settings?.class?.popup, 'flex flex-col max-h-[320px] overflow-y-auto overflow-x-hidden'], style: { "min-width": settings?.fill === 'cover' && `${box.width}px`, "max-width": settings?.fill === 'contain' && `${box.width}px` }, blur: hide });
};
const hide = () => {
context && context.container.parentElement && context.close();
select.value = "";
};
const add = (value: T) => {
if(container.value.includes(value)) return;
container.value = [...container.value, value];
};
const remove = (value: T) => {
container.value = container.value.filter(e => e !== value);
}
const render = (option: Option<T>): StoredOption<T> | undefined => {
if(option === undefined)
return;
return { item: option, dom: dom('div', { listeners: { click: (_e) => { add(option.value); !_e.ctrlKey && hide(); }, mouseenter: () => focus(option.value) }, class: [settings?.class?.option, 'data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer'] }, [ option?.render ? option?.render() : text(option.text) ]) };
}
const filter = (_value: string, option?: StoredOption<T>): StoredOption<T> | undefined => {
return option?.item?.text.toLowerCase().normalize().includes(_value) && !value.includes(option.item.value) ? option : undefined;
}
let disabled = settings?.disabled ?? false, optionElements: StoredOption<T>[] = [];
reactivity(options, (_options) => {
optionElements = currentOptions = _options.map(render).filter(e => !!e);
})
const select = dom('input', { listeners: { focus: show, input: () => {
focus();
currentOptions = context && select.value ? optionElements.map(e => filter(select.value.toLowerCase().trim().normalize(), e)).filter(e => !!e) : optionElements.filter(e => !!e);
context && context.container.replaceChildren(...currentOptions.map(e => e.dom));
}, keydown: (e) => {
switch(e.key.toLocaleLowerCase())
{
case 'arrowdown':
focus(currentOptions[clamp((focused ?? -1) + 1, 0, currentOptions.length - 1)]?.item?.value);
return;
case 'arrowup':
focus(currentOptions[clamp((focused ?? 1) - 1, 0, currentOptions.length - 1)]?.item?.value);
return;
case 'pageup':
focus(currentOptions[0]?.item?.value);
return;
case 'pagedown':
focus(currentOptions[currentOptions.length - 1]?.item?.value);
return;
case 'enter':
focused !== undefined ? currentOptions[focused]?.dom.click() : currentOptions[0]?.dom.click();
return;
case 'escape':
context?.close();
return;
case 'backspace':
if(select.value === '')
currentOptions[currentOptions.length - 1]?.item?.value && remove(currentOptions[currentOptions.length - 1]!.item!.value);
return;
default: return;
}
} }, attributes: { type: 'text', }, class: 'flex-1 outline-hidden px-3 leading-none appearance-none py-1 bg-light-10 dark:bg-dark-10 disabled:bg-light-20 dark:disabled:bg-dark-20 w-full' });
let value: T[] = reactive([]);
const container = dom('label', { class: [settings?.class?.container, 'inline-flex h-10 w-full outline-hidden px-1 items-center justify-between text-sm font-semibold leading-none border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 focus:dark:shadow-dark-40 hover:border-light-50 hover:dark: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 overflow-y-hidden overflow-x-auto no-scroll'] }, [ div('flex flex-row gap-2 item-center select-none py-1 shrink-0', { list: () => value, render: (v, _c) => _c ?? div('flex flex-row gap-2 items-center border border-light-35 dark:border-dark-35 py-1 ps-2 pe-1', [ span('text-sm', currentOptions.find(e => e.item?.value === v)?.item?.text), dom('span', { listeners: { click: () => remove(v) } }, [ icon('radix-icons:cross-1', { width: 10, height: 10, class: 'cursor-pointer text-light-60 dark:text-dark-60 hover:text-light-100 hover:dark:text-dark-100' }) ]) ]) }), select ]) as HTMLLabelElement & { disabled: boolean, value: T[] };
Object.defineProperty(container, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
container.toggleAttribute('data-disabled', disabled);
select.toggleAttribute('disabled', disabled);
},
})
Object.defineProperty(container, 'value', {
get: () => value,
set: (v: T[] | undefined) => {
settings?.change && value !== v && settings?.change(v as T[]);
value.splice(0, value.length, ...(v ?? []));
},
});
if(settings?.defaultValue) value.splice(0, value.length, ...settings?.defaultValue);
return container;
}
export function input(type: 'text' | 'number' | 'email' | 'password' | 'tel', settings?: { defaultValue?: string, change?: (value: string) => void, input?: (value: string) => void | boolean, focus?: () => void, blur?: () => void, class?: Class, disabled?: boolean, placeholder?: string }): HTMLInputElement
{
const input = dom("input", { attributes: { disabled: settings?.disabled, placeholder: settings?.placeholder }, 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: {
const input = dom("input", { attributes: { disabled: settings?.disabled, placeholder: settings?.placeholder, type: type }, class: [settings?.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-hidden px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 focus:dark:shadow-dark-40
border border-light-35 dark:border-dark-35 hover:border-light-50 hover:dark: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: {
input: (e) => { if(settings?.input && settings.input(input.value) === false) input.value = value; else value = input.value; },
change: () => settings?.change && settings.change(input.value),
focus: settings?.focus,
@@ -394,16 +615,22 @@ export function input(type: 'text' | 'number' | 'email' | 'password' | 'tel', se
return input;
}
export function numberpicker(settings?: { defaultValue?: number, change?: (value: number) => void, input?: (value: number) => void, focus?: (value: number) => void, blur?: (value: number) => void, class?: Class, min?: number, max?: number, disabled?: boolean }): HTMLInputElement
export function numberpicker(settings?: { defaultValue?: Reactive<number>, change?: (value: number) => void, input?: (value: number) => void, focus?: (value: number) => void, blur?: (value: number) => void, class?: Class, min?: number, max?: number, disabled?: Reactive<boolean> }): HTMLInputElement
{
let storedValue = settings?.defaultValue ?? 0;
const validateAndChange = (value: number) => {
let storedValue = settings?.defaultValue ? typeof settings.defaultValue === 'function' ? settings.defaultValue() : settings.defaultValue : 0;
const parseRaw = () => field.value.trim().toLowerCase().normalize().replace(/[a-z]/g, "").replace(/,/g, ".");
const validateAndChange = (value: number, fromInput = false) => {
const raw = parseRaw();
if(isNaN(value))
field.value = '';
{
if(!fromInput || (raw !== '' && raw !== '.'))
field.value = '';
}
else
{
value = clamp(value, settings?.min ?? -Infinity, settings?.max ?? Infinity);
field.value = value.toString(10);
if(!fromInput || !raw.endsWith('.'))
field.value = value.toString(10);
if(storedValue !== value)
{
storedValue = value;
@@ -412,8 +639,8 @@ export function numberpicker(settings?: { defaultValue?: number, change?: (value
}
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 disabled:shadow-none disabled:bg-light-20 dark:disabled:bg-dark-20 disabled:border-dashed disabled:border-light-30 dark:disabled:border-dark-30`, settings?.class], listeners: {
input: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.input && settings.input(storedValue),
const field = dom("input", { attributes: { disabled: settings?.disabled }, class: [settings?.class, `w-14 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 outline-hidden px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 focus:dark:shadow-dark-40 border border-light-35 dark:border-dark-35 hover:border-light-50 hover:dark:border-dark-50 disabled:shadow-none disabled:bg-light-20 dark:disabled:bg-dark-20 disabled:border-dashed disabled:border-light-35 dark:disabled:border-dark-35 disabled:border-2`], listeners: {
input: () => validateAndChange(parseFloat(parseRaw()), true) && settings?.input && settings.input(storedValue),
keydown: (e: KeyboardEvent) => {
if(field.disabled)
return;
@@ -433,22 +660,29 @@ export function numberpicker(settings?: { defaultValue?: number, change?: (value
settings?.min && validateAndChange(settings.min) && settings?.input && settings.input(storedValue);
break;
case "Enter":
validateAndChange(parseFloat(parseRaw()));
settings?.change && settings.change(storedValue);
break;
default:
return;
}
},
change: () => settings?.change && settings.change(storedValue),
change: () => { validateAndChange(parseFloat(parseRaw())); settings?.change && settings.change(storedValue); },
focus: () => settings?.focus && settings.focus(storedValue),
blur: () => settings?.blur && settings.blur(storedValue),
blur: () => { validateAndChange(parseFloat(parseRaw())); settings?.blur && settings.blur(storedValue); },
}});
if(settings?.defaultValue !== undefined) field.value = storedValue.toString(10);
if(settings?.defaultValue !== undefined)
{
reactivity(settings.defaultValue, (v) => {
field.value = v.toString(10);
storedValue = v;
})
}
return field;
}
// Open by default
export function foldable(content: Reactive<NodeChildren>, title: NodeChildren, settings?: { open?: boolean, class?: { container?: Class, title?: Class, content?: Class, icon?: Class } })
export function foldable(content: Reactive<NodeChildren>, title: NodeChildren, settings?: { open?: boolean, onFold?: (state: boolean) => void, class?: { container?: Class, title?: Class, content?: Class, icon?: Class } })
{
let lazyContent: NodeChildren;
const display = (state: boolean) => {
@@ -460,28 +694,29 @@ export function foldable(content: Reactive<NodeChildren>, title: NodeChildren, s
lazyContent && contentContainer.replaceChildren(...lazyContent.map(e => typeof e ==='function' ? e() : e).filter(e => !!e));
}
}
settings?.onFold && settings.onFold(state);
}
const contentContainer = div(['hidden group-data-[active]:flex', settings?.class?.content]);
const fold = div(['group flex w-full flex-col', settings?.class?.container], [
div('flex', [ dom('div', { listeners: { click: () => { display(fold.toggleAttribute('data-active')) } }, class: ['flex justify-center items-center', settings?.class?.icon] }, [ icon('radix-icons:caret-right', { class: 'group-data-[active]:rotate-90 origin-center' }) ]), div(['flex-1', settings?.class?.title], title) ]),
const contentContainer = div([settings?.class?.content, 'hidden group-data-[active]:flex']);
const fold = div([settings?.class?.container, 'group flex w-full flex-col'], [
div('flex', [ dom('div', { listeners: { click: () => { display(fold.toggleAttribute('data-active')) } }, class: [settings?.class?.icon, 'flex justify-center items-center'] }, [ icon('radix-icons:caret-right', { class: 'group-data-[active]:rotate-90 origin-center' }) ]), div([settings?.class?.title, 'flex-1'], title) ]),
contentContainer
]);
display(settings?.open ?? true);
fold.toggleAttribute('data-active', settings?.open ?? true);
return fold;
}
type TableRow = Record<string, (() => RedrawableHTML) | RedrawableHTML | string>;
export function table(content: TableRow[], headers: TableRow, properties?: { class?: { table?: Class, header?: Class, body?: Class, row?: Class, cell?: Class } })
type TableRow<T extends string> = Record<T, (() => HTMLElement) | HTMLElement | string>;
export function table<T extends string>(content: TableRow<T>[], headers: TableRow<T>, properties?: { class?: { table?: Class, header?: Class, body?: Class, row?: Class, cell?: Class } })
{
const render = (item: (() => RedrawableHTML) | RedrawableHTML | string) => typeof item === 'string' ? text(item) : typeof item === 'function' ? item() : item;
return dom('table', { class: ['', properties?.class?.table] }, [ dom('thead', { class: ['', properties?.class?.header] }, [ dom('tr', { class: '' }, Object.values(headers).map(e => dom('th', {}, [ render(e) ]))) ]), dom('tbody', { class: ['', properties?.class?.body] }, content.map(e => dom('tr', { class: ['', properties?.class?.row] }, Object.keys(headers).map(f => e.hasOwnProperty(f) ? dom('td', { class: ['', properties?.class?.cell] }, [ render(e[f]!) ]) : undefined)))) ]);
const render = (item: (() => HTMLElement) | HTMLElement | string) => typeof item === 'string' ? text(item) : typeof item === 'function' ? item() : item;
return dom('table', { class: [properties?.class?.table] }, [ dom('thead', { class: [properties?.class?.header] }, [ dom('tr', { class: '' }, Object.values(headers).map(e => dom('th', {}, [ render(e) ]))) ]), dom('tbody', { class: [properties?.class?.body] }, content.map(e => dom('tr', { class: [properties?.class?.row] }, Object.keys(headers).map(f => e.hasOwnProperty(f) ? dom('td', { class: [properties?.class?.cell] }, [ render(e[f]!) ]) : undefined)))) ]);
}
export function toggle(settings?: { defaultValue?: boolean, change?: (value: boolean) => void, disabled?: boolean, class?: { container?: Class } })
export function toggle(settings?: { defaultValue?: boolean, change?: (value: boolean) => void, disabled?: Reactive<boolean>, class?: { container?: Class } })
{
let state = settings?.defaultValue ?? false;
const element = 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-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, settings?.class?.container], attributes: { "data-state": state ? "checked" : "unchecked", "data-disabled": settings?.disabled }, listeners: {
const element = dom("div", { class: [settings?.class?.container, `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-hidden
data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 hover:dark:border-dark-50 focus:shadow-raw focus:shadow-light-40 focus:dark: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": state ? "checked" : "unchecked", "data-disabled": settings?.disabled }, listeners: {
click: function(e: Event) {
if(this.hasAttribute('data-disabled'))
return;
@@ -494,56 +729,83 @@ export function toggle(settings?: { defaultValue?: boolean, change?: (value: boo
}, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]);
return element;
}
export function checkbox(settings?: { defaultValue?: boolean, change?: (this: RedrawableHTML, value: boolean) => void, disabled?: boolean, class?: { container?: Class, icon?: Class } })
export function checkbox(settings?: { defaultValue?: boolean, change?: (this: HTMLElement, value: boolean) => void | boolean, disabled?: Reactive<boolean>, class?: { container?: Class, icon?: Class } })
{
let state = settings?.defaultValue ?? false;
const element = dom("div", { class: [`group w-6 h-6 box-content flex items-center justify-center border border-light-50 dark:border-dark-50 bg-light-20 dark:bg-dark-20
cursor-pointer hover:bg-light-30 dark:hover:bg-dark-30 hover:border-light-60 dark:hover:border-dark-60
data-[disabled]:cursor-default data-[disabled]:border-dashed data-[disabled]:border-light-40 dark:data-[disabled]:border-dark-40 data-[disabled]:bg-0 dark:data-[disabled]:bg-0 hover:data-[disabled]:bg-0 dark:hover:data-[disabled]:bg-0`, settings?.class?.container], attributes: { "data-state": state ? "checked" : "unchecked", "data-disabled": settings?.disabled }, listeners: {
const element = dom("div", { class: [settings?.class?.container, `group w-6 h-6 box-content flex items-center justify-center border border-light-50 dark:border-dark-50 bg-light-20 dark:bg-dark-20
cursor-pointer hover:bg-light-30 hover:dark:bg-dark-30 hover:border-light-60 hover:dark:border-dark-60
data-[disabled]:cursor-default data-[disabled]:border-dashed data-[disabled]:border-light-40 dark:data-[disabled]:border-dark-40 data-[disabled]:bg-0 dark:data-[disabled]:bg-0 hover:data-[disabled]:bg-0 hover:dark:data-[disabled]:bg-0`], attributes: { "data-state": state ? "checked" : "unchecked", "data-disabled": settings?.disabled }, listeners: {
click: function(e: Event) {
if(this.hasAttribute('data-disabled'))
return;
state = !state;
element.setAttribute('data-state', state ? "checked" : "unchecked");
settings?.change && settings.change.bind(this)(state);
if(settings?.change && settings.change.bind(this)(!state) !== false)
{
state = !state;
element.setAttribute('data-state', state ? "checked" : "unchecked");
}
}
}
}, [ icon('radix-icons:check', { width: 14, height: 14, class: ['hidden group-data-[state="checked"]:block data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50', settings?.class?.icon] }), ]);
}, [ icon('radix-icons:check', { width: 14, height: 14, class: [settings?.class?.icon, 'hidden group-data-[state="checked"]:block data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50'] }), ]);
return element;
}
export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content: Reactive<NodeChildren> }>, settings?: { focused?: string, class?: { container?: Class, tabbar?: Class, title?: Class, content?: Class }, switch?: (tab: string) => void | boolean }): RedrawableHTML
export function tabgroup(tabs: Array<Reactive<{ id: string, title: NodeChildren, content: Reactive<NodeChildren> } | undefined>>, settings?: { focused?: string, class?: { container?: Class, tabbar?: Class, title?: Class, content?: Class }, switch?: (tab: string) => void | boolean }): HTMLElement
{
let focus = settings?.focused ?? tabs[0]?.id;
const titles = tabs.map(e => dom('div', { class: ['px-2 py-1 border-b border-transparent hover:border-accent-blue data-[focus]:border-accent-blue data-[focus]:border-b-[3px] cursor-pointer', settings?.class?.title], attributes: { 'data-focus': e.id === focus }, listeners: { click: function() {
if(this.hasAttribute('data-focus'))
return;
let focus = settings?.focused ?? '';
if(settings?.switch && settings.switch(e.id) === false)
return;
const resolveTab = (t: typeof tabs[number]) => typeof t === 'function' ? t() : t;
titles.forEach(e => e.toggleAttribute('data-focus', false));
this.toggleAttribute('data-focus', true);
focus = e.id;
const lazyContent = typeof e.content === 'function' ? e.content() : e.content;
lazyContent && content.replaceChildren(...lazyContent?.map(e => typeof e === 'function' ? e() : e)?.filter(e => !!e));
}}}, e.title));
const _content = tabs.find(e => e.id === focus)?.content;
const content = div(['', settings?.class?.content], typeof _content === 'function' ? _content() : _content);
const tabbar = div([settings?.class?.tabbar, 'flex flex-row items-center gap-1']);
const content = div([settings?.class?.content]);
const container = div([settings?.class?.container, 'flex flex-col'], [tabbar, content]);
const container = div(['flex flex-col', settings?.class?.container], [
div(['flex flex-row items-center gap-1', settings?.class?.tabbar], titles),
content
]);
return container as RedrawableHTML;
const render = () => {
const resolved = tabs.map(resolveTab).filter((t): t is NonNullable<typeof t> => !!t);
if (!resolved.find(t => t.id === focus))
focus = resolved[0]?.id ?? '';
const titles = resolved.map(t => dom('div', {
class: [settings?.class?.title, 'px-2 py-1 border-b border-transparent hover:border-accent-blue data-[focus]:border-accent-blue data-[focus]:border-b-[3px] cursor-pointer'],
attributes: focus === t.id ? { 'data-focus': '' } : {},
listeners: { click: function() {
if (this.hasAttribute('data-focus'))
return;
if (settings?.switch && settings.switch(t.id) === false)
return;
tabbar.querySelectorAll('[data-focus]').forEach(e => e.removeAttribute('data-focus'));
this.setAttribute('data-focus', '');
focus = t.id;
const lazyContent = typeof t.content === 'function' ? t.content() : t.content;
lazyContent && content.replaceChildren(...lazyContent.map(e => typeof e === 'function' ? e() : e).filter(e => !!e));
} }
}, t.title));
tabbar.replaceChildren(...titles);
const active = resolved.find(t => t.id === focus);
if (active) {
const lazyContent = typeof active.content === 'function' ? active.content() : active.content;
lazyContent && content.replaceChildren(...lazyContent.map(e => typeof e === 'function' ? e() : e).filter(e => !!e));
}
};
reactivity(() => {
const resolved = tabs.map(resolveTab);
return resolved.map(t => t?.id).join(',');
}, render);
return container as HTMLElement;
}
export function floater(container: RedrawableHTML, content: NodeChildren | (() => NodeChildren), settings?: { delay?: number, href?: RouteLocationRaw, class?: Class, style?: Record<string, string | undefined | boolean | number> | string, position?: Placement, pinned?: boolean | { width: number, height: number }, minimizable?: boolean, cover?: 'width' | 'height' | 'all' | 'none', events?: { show: Array<keyof HTMLElementEventMap>, hide: Array<keyof HTMLElementEventMap>, onshow?: (state: FloatState) => boolean, onhide?: (state: FloatState) => boolean }, title?: string })
export function floater(container: HTMLElement, content: NodeChildren | (() => NodeChildren), settings?: { delay?: number, href?: RouteLocationRaw, class?: Class, style?: Record<string, string | undefined | boolean | number> | string, position?: Placement, pinned?: boolean | { width: number, height: number }, minimizable?: boolean, cover?: 'width' | 'height' | 'all' | 'none', events?: { show: Array<keyof HTMLElementEventMap>, hide: Array<keyof HTMLElementEventMap>, onshow?: (state: FloatState) => boolean, onhide?: (state: FloatState) => boolean }, title?: string })
{
let viewport = document.getElementById('mainContainer') ?? undefined;
let diffX, diffY;
let minimizeRect: DOMRect, minimized = false;
const events: { show: Array<keyof HTMLElementEventMap>, hide: Array<keyof HTMLElementEventMap>, onshow?: (this: RedrawableHTML, state: FloatState) => boolean, onhide?: (this: RedrawableHTML, state: FloatState) => boolean } = Object.assign({
const events: { show: Array<keyof HTMLElementEventMap>, hide: Array<keyof HTMLElementEventMap>, onshow?: (this: HTMLElement, state: FloatState) => boolean, onhide?: (this: HTMLElement, state: FloatState) => boolean } = Object.assign({
show: ['mouseenter', 'mousemove', 'focus'],
hide: ['mouseleave', 'blur'],
} as { show: Array<keyof HTMLElementEventMap>, hide: Array<keyof HTMLElementEventMap> }, settings?.events ?? {});
@@ -634,7 +896,7 @@ export function floater(container: RedrawableHTML, content: NodeChildren | (() =
if(!floating.content.hasAttribute('data-pinned'))
return;
[...this.parentElement?.children ?? []].forEach(e => (e as any as RedrawableHTML).attributeStyleMap.set('z-index', -1));
[...this.parentElement?.children ?? []].forEach(e => (e as any as HTMLElement).attributeStyleMap.set('z-index', -1));
this.attributeStyleMap.set('z-index', 0);
}, { passive: true });
}
@@ -696,7 +958,7 @@ export function floater(container: RedrawableHTML, content: NodeChildren | (() =
});
minimized = false;
} } }, [icon('radix-icons:cross-1', { width: 12, height: 12, class: 'p-1' })]), 'Fermer', 'top') ]) : undefined,
div(['group-data-[minimized]:hidden h-full group-data-[pinned]:h-[calc(100%-21px)] w-full group-data-[pinned]:min-h-[initial] group-data-[pinned]:min-w-[initial] group-data-[pinned]:max-h-[initial] group-data-[pinned]:max-w-[initial] overflow-auto box-content', settings?.class], typeof content === 'function' ? content() : content), dom('span', { class: 'hidden group-data-[pinned]:flex group-data-[minimized]:hidden absolute bottom-0 right-0 cursor-nw-resize z-50', listeners: { mousedown: resizestart } }, [ icon('ph:notches', { width: 12, height: 12 }) ])
div([settings?.class, 'group-data-[minimized]:hidden h-full group-data-[pinned]:h-[calc(100%-21px)] w-full group-data-[pinned]:min-h-[initial] group-data-[pinned]:min-w-[initial] group-data-[pinned]:max-h-[initial] group-data-[pinned]:max-w-[initial] overflow-auto box-content'], typeof content === 'function' ? content() : content), dom('span', { class: 'hidden group-data-[pinned]:flex group-data-[minimized]:hidden absolute bottom-0 right-0 cursor-nw-resize z-50', listeners: { mousedown: resizestart } }, [ icon('ph:notches', { width: 12, height: 12 }) ])
],
viewport,
events: events
@@ -717,19 +979,19 @@ export interface ToastConfig
timer?: boolean
type?: ToastType
}
type ToastDom = ToastConfig & { dom: RedrawableHTML };
type ToastDom = ToastConfig & { dom: HTMLElement };
export type ToastType = 'info' | 'success' | 'error';
export class Toaster
{
private static _MAX_DRAG = 150;
private static _list: Array<ToastDom> = [];
private static _container: RedrawableHTML;
private static _container: HTMLElement;
static init()
{
Toaster.dispose();
Toaster._container = dom('div', { attributes: { id: 'toaster' }, class: 'fixed bottom-0 right-0 flex flex-col p-6 gap-2 max-w-[512px] z-50 outline-none min-w-72 empty:hidden' });
Toaster._container = dom('div', { attributes: { id: 'toaster' }, class: 'fixed bottom-0 right-0 flex flex-col p-6 gap-2 max-w-[512px] z-50 outline-hidden min-w-72 empty:hidden' });
document.body.appendChild(Toaster._container);
}
static dispose()
@@ -773,7 +1035,7 @@ export class Toaster
config.closeable ? dom('span', { class: 'translate-x-4 text-light-100 dark:text-dark-100', listeners: { click: () => Toaster.close(config), } }, [ icon('radix-icons:cross-1', { width: 12, height: 12, class: 'cursor-pointer' }) ]) : undefined,
config.content ? dom('span', { class: 'text-sm col-span-8 text-light-100 dark:text-dark-100', text: config.content }) : undefined,
]),
config.timer ? dom('div', { class: 'relative overflow-hidden bg-light-25 dark:bg-dark-25 h-1 mb-0 mt-0 w-full group-data-[type=error]:*:bg-light-red dark:group-data-[type=error]:*:bg-dark-red group-data-[type=success]:*:bg-light-green dark:group-data-[type=success]:*:bg-dark-green group-data-[type=error]:bg-light-red dark:group-data-[type=error]:bg-dark-red group-data-[type=success]:bg-light-green dark:group-data-[type=success]:bg-dark-green !bg-opacity-50' }, [ loader ]) : undefined
config.timer ? dom('div', { class: 'relative overflow-hidden bg-light-25/50 dark:bg-dark-25/50 h-1 mb-0 mt-0 w-full group-data-[type=error]:*:bg-light-red/50 dark:group-data-[type=error]:*:bg-dark-red/50 group-data-[type=success]:*:bg-light-green/50 dark:group-data-[type=success]:*:bg-dark-green/50 group-data-[type=error]:bg-light-red/50 dark:group-data-[type=error]:bg-dark-red/50 group-data-[type=success]:bg-light-green/50 dark:group-data-[type=success]:bg-dark-green/50' }, [ loader ]) : undefined
]);
config.dom.addEventListener('mousedown', dragstart);
config.dom.animate([{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }], { duration: 150, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
@@ -793,4 +1055,23 @@ export class Toaster
], { easing: 'ease-in', duration: 100 }).onfinish = () => config.dom.remove();
Toaster._list = Toaster._list.filter(e => e !== config);
}
}
export class DiceRoller
{
private static _dom: HTMLElement;
static init()
{
DiceRoller.dispose();
DiceRoller._dom = dom('div', { attributes: { id: 'dice-roller' }, class: 'fixed bottom-0 right-0 flex flex-col p-6 gap-2 max-w-[512px] z-50 outline-hidden min-w-72 empty:hidden' });
document.body.appendChild(DiceRoller._dom);
}
static dispose()
{
DiceRoller._dom?.remove();
}
static get dom()
{
return DiceRoller._dom;
}
}

View File

@@ -1,14 +1,14 @@
import { safeDestr as parse } from 'destr';
import { Canvas, CanvasEditor } from "#shared/canvas.util";
import render, { renderMDAsText } from "#shared/markdown.util";
import { confirm, contextmenu, tooltip } from "#shared/floating.util";
import { cancelPropagation, dom, icon, text, type Node, type RedrawableHTML } from "#shared/dom.util";
import { loading } from "#shared/components.util";
import { Canvas, CanvasEditor } from "#shared/canvas";
import render, { renderMDAsText } from "#shared/markdown";
import { confirm, contextmenu, tooltip } from "#shared/floating";
import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom";
import { loading } from "#shared/components";
import prose, { h1, h2 } from "#shared/proses";
import { getID, lerp, parsePath } from '#shared/general.util';
import { getID, lerp, parsePath } from '~~/shared/general';
import { TreeDOM, type Recursive } from '#shared/tree';
import { History } from '#shared/history.util';
import { MarkdownEditor } from '#shared/editor.util';
import { History } from '~~/shared/history';
import { MarkdownEditor } from '~~/shared/editor';
import type { CanvasContent } from '~/types/canvas';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
@@ -18,7 +18,7 @@ import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-sc
import type { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';
import type { CharacterConfig, FeatureID, MainStat, TrainingLevel } from '~/types/character';
import { getText } from './i18n';
import { mainStatTexts } from './character.util';
import { mainStatTexts } from './character';
export type FileType = keyof ContentMap;
export interface ContentMap
@@ -141,13 +141,13 @@ export class Content
const overview = await Content.read('overview', { create: true });
try
{
Content._overview = parse<Record<string, Omit<LocalContent, 'content'>>>(overview);
Content._overview = reactive(parse<Record<string, Omit<LocalContent, 'content'>>>(overview));
Content._reverseMapping = Object.values(Content._overview).reduce((p, v) => { p[v.path] = v.id; return p; }, {} as Record<string, string>);
await Content.pull();
}
catch(e)
{
Content._overview = {};
Content._overview = reactive({});
await Content.pull(true);
}
@@ -184,7 +184,30 @@ export class Content
if(!overview)
return;
return { ...overview, content: Content.fromString(overview, (await Content.read(id, { create: true }))!) };
let content = await Content.read(id);
if(content === undefined)
{
try
{
const apiContent = await useRequestFetch()(`/api/file/content/${id}`, { cache: 'no-cache' });
if(apiContent)
{
content = apiContent;
if(overview.type !== 'folder')
Content.queue.queue(() => Content.write(id, content!, { create: true }));
}
else
{
overview.error = true;
}
}
catch(e)
{
overview.error = true;
}
}
return { ...overview, content: Content.fromString(overview, content!) };
}
static set(id: string, overview?: Omit<LocalContent, 'content'> | Recursive<Omit<LocalContent, 'content'>>)
{
@@ -251,20 +274,6 @@ export class Content
{
Content._overview[file.id] = file;
Content._reverseMapping[file.path] = file.id;
Content.queue.queue(() => {
return useRequestFetch()(`/api/file/content/${file.id}`, { cache: 'no-cache' }).then(async (content: string | undefined | null) => {
if(content)
{
if(file.type !== 'folder')
Content.queue.queue(() => Content.write(file.id, content, { create: true }));
}
else
Content._overview[file.id]!.error = true;
}).catch(e => {
Content._overview[file.id]!.error = true;
});
});
}
deletable.splice(deletable.findIndex(e => e === file.id), 1);
@@ -284,11 +293,12 @@ export class Content
}
static async push()
{
const blocked = (await useRequestFetch()('/api/file/overview', { method: 'POST', body: Object.values(Content._overview), cache: 'no-cache' }));
const requested = (await useRequestFetch()('/api/file/overview', { method: 'POST', body: Object.values(Content._overview), cache: 'no-cache' }));
for(const [id, value] of Object.entries(Content._overview).filter(e => !blocked.includes(e[0])))
for(let id of requested)
{
if(value.type === 'folder')
const value = Content.get(id);
if(!value || value.type === 'folder')
continue;
Content.queue.queue(() => Content.read(id).then(e => {
@@ -381,7 +391,7 @@ export class Content
return handlers[overview.type].fromString(content);
}
static async render(parent: RedrawableHTML, path: string): Promise<Omit<LocalContent, 'content'> | undefined>
static async render(parent: HTMLElement, path: string): Promise<Omit<LocalContent, 'content'> | undefined>
{
parent.appendChild(dom('div', { class: 'flex, flex-1 justify-center items-center' }, [loading('normal')]))
@@ -489,7 +499,7 @@ const handlers: { [K in FileType]: ContentTypeHandler<K> } = {
return c.container;
},
renderEditor: (content) => {
let element: RedrawableHTML;
let element: HTMLElement;
if(content.hasOwnProperty('content'))
{
const c = new CanvasEditor(content.content);
@@ -527,7 +537,7 @@ const handlers: { [K in FileType]: ContentTypeHandler<K> } = {
])
},
renderEditor: (content) => {
let element: RedrawableHTML;
let element: HTMLElement;
if(content.hasOwnProperty('content'))
{
MarkdownEditor.singleton.content = content.content;
@@ -582,11 +592,11 @@ export const iconByType: Record<FileType, string> = {
export class Editor
{
tree!: TreeDOM;
container: RedrawableHTML;
container: HTMLElement;
selected?: Recursive<LocalContent & { element?: RedrawableHTML }>;
selected?: Recursive<LocalContent & { element?: HTMLElement }>;
private instruction: RedrawableHTML;
private instruction: HTMLElement;
private cleanup?: CleanupFn;
private history: History;
@@ -638,7 +648,7 @@ export class Editor
if(!action.element)
{
const depth = getPath(action.element as LocalContent).split('/').length;
action.element.element = this.tree.render(action.element as LocalContent, depth) as RedrawableHTML;
action.element.element = this.tree.render(action.element as LocalContent, depth) as HTMLElement;
this.dragndrop(action.element as LocalContent, depth, (action.element as Recursive<LocalContent>).parent);
}
this.tree.tree.insertAt(action.element as Recursive<LocalContent>, action.to as number);
@@ -651,6 +661,7 @@ export class Editor
this.tree.update();
},
redo: (action) => {
this.tree.tree.remove(action.element.id);
if(this.selected === action.element) this.select();
action.element.cleanup();
@@ -703,14 +714,14 @@ export class Editor
Content.ready.then(() => {
this.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 }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent)} }, [
return dom('div', { class: 'group flex items-center ps-2 outline-hidden 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 }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent)} }, [
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` } }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
tooltip(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), 'Navigable', 'left'),
tooltip(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), 'Privé', 'right'),
])]);
}, (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'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent), click: () => this.select(item as LocalContent) } }, [
return dom('div', { class: 'group flex items-center ps-2 outline-hidden 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'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent), click: () => this.select(item as LocalContent) } }, [
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 } }),
tooltip(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), 'Navigable', 'left'),
@@ -718,7 +729,7 @@ export class Editor
])]);
});
this.select(this.tree.tree.find(useRouter().currentRoute.value.hash.substring(1)) as Recursive<LocalContent & { element?: RedrawableHTML }> | undefined);
this.select(this.tree.tree.find(useRouter().currentRoute.value.hash.substring(1)) as Recursive<LocalContent & { element?: HTMLElement }> | undefined);
this.cleanup = this.setupDnD();
});
@@ -732,22 +743,22 @@ export class Editor
e.preventDefault();
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.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 hover:dark: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 hover:dark: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 hover:dark: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')]),
], { placement: 'right-start', offset: 8 });
}
private add(type: FileType, nextTo: Recursive<LocalContent>)
{
const count = Object.values(Content.files).filter(e => e.title.match(/^Nouveau( \(\d+\))?$/)).length;
const item: Recursive<Omit<LocalContent, 'path' | 'content'> & { element?: RedrawableHTML }> = { id: getID(), navigable: true, private: false, owner: 0, order: nextTo.order + 1, timestamp: new Date(), title: count === 0 ? 'Nouveau' : `Nouveau (${count})`, type: type, parent: nextTo.parent };
this.history.add('overview', 'add', [{ element: item, from: undefined, to: nextTo.order + 1 }]);
const item: Recursive<Omit<LocalContent, 'path' | 'content'> & { element?: HTMLElement }> = { id: getID(), navigable: true, private: false, owner: 0, order: nextTo.order + 1, timestamp: new Date(), title: count === 0 ? 'Nouveau' : `Nouveau (${count})`, type: type, parent: nextTo.parent };
this.history.add('overview', [{ element: item, from: undefined, to: nextTo.order + 1, event: 'add' }]);
}
private remove(item: LocalContent & { element?: RedrawableHTML })
private remove(item: LocalContent & { element?: HTMLElement })
{
this.history.add('overview', 'remove', [{ element: item, from: item.order, to: undefined }], true);
this.history.add('overview', [{ element: item, from: item.order, to: undefined, event: 'remove' }, { element: item, from: item.timestamp, to: new Date(), event: 'timestamp' }], true);
}
private rename(item: LocalContent & { element?: RedrawableHTML })
private rename(item: LocalContent & { element?: HTMLElement })
{
let exists = true;
const change = () =>
@@ -760,7 +771,7 @@ export class Editor
input.parentElement?.replaceChild(text!, input);
input.remove();
if(value !== item.title) this.history.add('overview', 'rename', [{ element: item, from: item.title, to: value }], true);
if(value !== item.title) this.history.add('overview', [{ element: item, from: item.title, to: value, event: 'rename' }, { element: item, from: item.timestamp, to: new Date(), event: 'timestamp' }], true);
}
}
const text = item.element!.children[0]?.children[1];
@@ -769,17 +780,17 @@ export class Editor
text?.parentElement?.replaceChild(input, text);
input.focus();
}
private toggleNavigable(e: Event, item: LocalContent & { element?: RedrawableHTML })
private toggleNavigable(e: Event, item: LocalContent & { element?: HTMLElement })
{
cancelPropagation(e);
this.history.add('overview', 'navigable', [{ element: item, from: item.navigable, to: !item.navigable }], true);
this.history.add('overview', [{ element: item, from: item.navigable, to: !item.navigable, event: 'navigable' }, { element: item, from: item.timestamp, to: new Date(), event: 'timestamp' }], true);
}
private togglePrivate(e: Event, item: LocalContent & { element?: RedrawableHTML })
private togglePrivate(e: Event, item: LocalContent & { element?: HTMLElement })
{
cancelPropagation(e);
this.history.add('overview', 'private', [{ element: item, from: item.private, to: !item.private }], true);
this.history.add('overview', [{ element: item, from: item.private, to: !item.private, event: 'private' }, { element: item, from: item.timestamp, to: new Date(), event: 'timestamp' }], true);
}
private setupDnD(): CleanupFn
{
@@ -800,7 +811,7 @@ export class Editor
element: this.tree.container,
}));
}
private dragndrop(item: Omit<LocalContent & { element?: RedrawableHTML, cleanup?: () => void }, "content">, depth: number, parent?: Omit<LocalContent & { element?: RedrawableHTML }, "content">): CleanupFn
private dragndrop(item: Omit<LocalContent & { element?: HTMLElement, cleanup?: () => void }, "content">, depth: number, parent?: Omit<LocalContent & { element?: HTMLElement }, "content">): CleanupFn
{
item.cleanup && item.cleanup();
@@ -884,19 +895,19 @@ export class Editor
return;
if (instruction.type === 'reorder-above')
this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive<typeof targetItem>).parent, order: targetItem!.order }}], true);
this.history.add('overview', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive<typeof targetItem>).parent, order: targetItem!.order }, event: 'move' }, { element: sourceItem, from: sourceItem.timestamp, to: new Date(), event: 'timestamp' }], true);
if (instruction.type === 'reorder-below')
this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive<typeof targetItem>).parent, order: targetItem!.order + 1 }}], true);
this.history.add('overview', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive<typeof targetItem>).parent, order: targetItem!.order + 1 }, event: 'move' }, { element: sourceItem, from: sourceItem.timestamp, to: new Date(), event: 'timestamp' }], true);
if (instruction.type === 'make-child' && targetItem.type === 'folder')
this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: targetItem, order: 0 }}], true);
this.history.add('overview', [{ element: sourceItem, from: from, to: { parent: targetItem, order: 0 }, event: 'move' }, { element: sourceItem, from: sourceItem.timestamp, to: new Date(), event: 'timestamp' }], true);
}
private render<T extends FileType>(item: LocalContent<T>): Node
{
return handlers[item.type].renderEditor(item);
}
private select(item?: LocalContent & { element?: RedrawableHTML })
private select(item?: LocalContent & { element?: HTMLElement })
{
if(this.selected && item)
{
@@ -917,12 +928,20 @@ export class Editor
useRouter().push({ hash: this.selected ? '#' + this.selected.id : '' })
this.container.firstElementChild!.replaceChildren();
this.selected && this.container.firstElementChild!.appendChild(this.render(this.selected) as RedrawableHTML);
this.selected && this.container.firstElementChild!.appendChild(this.render(this.selected) as HTMLElement);
}
unmount()
{
this.cleanup && this.cleanup();
}
undo()
{
this.history.undo();
}
redo()
{
this.history.redo();
}
}
export function getPath(item: Recursive<Omit<LocalContent, 'content'>>): string
@@ -937,8 +956,9 @@ export function getPath(item: any): string
return parsePath(item.title) ?? item.path;
}
/* export function buildSpellMD()
import characterConfig from '#shared/character-config.json';
const config = characterConfig as CharacterConfig;
export function buildSpellMD()
{
const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","nature","light","psyche"];
const SPELL_TYPE_TEXTS = { "instinct": "Instinct", "knowledge": "Savoir", "precision": "Précision", "arts": "Oeuvres" };
@@ -981,4 +1001,4 @@ export function buildTrainingFile()
return Object.entries(config.training).map(e => {
return `# ${mainStatTexts[e[0] as MainStat]}\n` + Object.entries(e[1]).map(_e => `## Niveau ${_e[0]}\n` + _e[1].map(feature => renderMDAsText(getText(config.features[feature]!.description))).join('\nou\n')).join('\n');
}).join('\n');
} */
}

244
shared/dice.ts Normal file
View File

@@ -0,0 +1,244 @@
import type { MainStat } from "~/types/character";
import { MAIN_STATS, mainStatShortTexts } from "./character";
import type { Proxy } from "./reactive";
//[Quantité de dés, Nb de face du dé (1 = pas besoin de roll de dé), explosif]
export type Dice = [number, number | MainStat] | [number, number | MainStat, true];
export type DiceRoll = Dice[];
enum State {
START,
QUANTITY,
D_LETTER,
FACES_NUMBER,
FACES_STAT,
MODIFIER_SIGN,
MODIFIER,
EXPLODING,
END_DICE,
};
export function parseDice(formula: string): DiceRoll
{
const result: DiceRoll = [];
let state = State.START;
let i = 0, length = formula.length;
//Variables utilitaires de la State machine
let buffer = "";
let isExploding = false, isNegative = false, currentQty = 1, currentFaces: number | MainStat = 1;
const reset = () => {
if (state !== State.START && state !== State.END_DICE)
{
const dice: Dice = isExploding ? [currentQty, currentFaces, true] : [currentQty, currentFaces];
if (isNegative)
{
dice[0] = -dice[0];
isNegative = false;
}
result.push(dice);
}
currentQty = 1;
currentFaces = 1;
isExploding = false;
buffer = "";
};
while(i <= length)
{
const char = i < length ? formula[i]?.toLowerCase() ?? '\0' : '\0';
const isDigit = char >= '0' && char <= '9';
const isLetter = char >= 'a' && char <= 'z';
const isEnd = i === length;
switch (state) {
case State.START:
if (isDigit)
{
buffer = char;
state = State.QUANTITY;
}
else if (char === 'd')
{
currentQty = 1;
state = State.D_LETTER;
}
else if (isLetter)
{
buffer = char;
state = State.FACES_STAT;
}
else if (char === '+' || char === '-')
{
isNegative = char === '-';
state = State.MODIFIER_SIGN;
}
else if (!isEnd && char !== ' ' && char !== '\t')
throw new Error(`Caractère inattendu '${char}' à la position ${i}`);
break;
case State.QUANTITY:
if (isDigit)
buffer += char;
else if (char === 'd')
{
currentQty = parseInt(buffer, 10) || 1;
buffer = "";
state = State.D_LETTER;
}
else
{
currentQty = parseInt(buffer, 10);
currentFaces = 1;
reset();
state = State.END_DICE;
i--;
}
break;
case State.D_LETTER:
if (isDigit)
{
buffer = char;
state = State.FACES_NUMBER;
}
else if (isLetter)
{
buffer = 'd' + char;
state = State.FACES_STAT;
}
else
throw new Error(`Attendu nombre ou stat après 'd' à la position ${i}`);
break;
case State.FACES_NUMBER:
if (isDigit)
buffer += char;
else if (char === '!')
{
currentFaces = parseInt(buffer, 10);
state = State.EXPLODING;
}
else
{
currentFaces = parseInt(buffer, 10);
reset();
state = State.END_DICE;
i--;
}
break;
case State.FACES_STAT:
if (isLetter && buffer.length < 3)
buffer += char;
else if (char === '!')
{
const stat = validStat(buffer);
if (!stat)
throw new Error(`Stat invalide '${buffer}' à la position ${i - buffer.length}`);
currentFaces = stat;
state = State.EXPLODING;
}
else
{
const stat = validStat(buffer);
if (!stat)
throw new Error(`Stat invalide '${buffer}' à la position ${i - buffer.length}`);
currentFaces = stat;
reset();
state = State.END_DICE;
i--;
}
break;
case State.EXPLODING:
isExploding = true;
reset();
state = State.END_DICE;
i--;
break;
case State.MODIFIER_SIGN:
if (isDigit)
{
buffer = char;
state = State.MODIFIER;
}
else if (char === 'd')
{
currentQty = 1;
state = State.D_LETTER;
}
else if (isLetter)
{
buffer = char;
state = State.FACES_STAT;
}
else
throw new Error(`Attendu nombre après '${isNegative ? "-" : "+"}' à la position ${i}`);
break;
case State.MODIFIER:
if (isDigit)
buffer += char;
else
{
currentQty = parseInt(buffer, 10);
currentFaces = 1;
if (isNegative) currentQty = -currentQty;
reset();
isNegative = false;
state = State.END_DICE;
i--;
}
break;
case State.END_DICE:
if (char === '+' || char === '-')
{
isNegative = char === '-';
state = State.MODIFIER_SIGN;
}
else if (char === 'd')
{
currentQty = 1;
state = State.D_LETTER;
}
else if (!isEnd && char !== ' ' && char !== '\t')
throw new Error(`Opérateur attendu, trouvé '${char}' à la position ${i}`);
break;
}
i++;
}
return result;
}
export function stringifyRoll(dices: DiceRoll, modifiers?: Proxy<Partial<Record<MainStat, number>>>, explicit?: boolean): string
{
const map = new Map<string, number>();
dices.forEach(e => {
if(e[0] === 0 || e[1] === 0) return;
const target = typeof e[1] === 'string' ? modifiers && e[1] in modifiers ? modifiers[e[1]]! * e[0] : `@${e[1]}` : e[0]; //@ character used to sort the stat dices at the end
const key = typeof e[1] === 'string' ? typeof target === 'string' ? e[1] : '1' : e[2] ? e[1].toString(10) + '!' : e[1].toString(10), value = map.get(key);
if(typeof target === 'string') map.has(target) ? map.set(target, 1) : map.set(target, map.get(target)! + 1);
else value === undefined ? map.set(key, target) : map.set(key, value + target);
});
const stringify = ([face, amount]: [string, number]) => (face.startsWith('@') && MAIN_STATS.includes(face.substring(1) as MainStat)) || face === '1' ? `${amount < 0 ? '-' : '+'}${face.startsWith('@') && MAIN_STATS.includes(face.substring(1) as MainStat) ? mainStatShortTexts[face.substring(1) as MainStat]! : amount}` : `${amount < 0 ? '-' : '+'}${explicit ?? false ? amount : amount !== 1 ? amount : ''}d${face}`;
const text = [...map.entries()].sort((a, b) => b[0].localeCompare(a[0])).reduce((p, v) => p + stringify(v), '');
return text.startsWith('+') ? text.substring(1) : text;
}
function validStat(text: string): MainStat | undefined
{
return Object.entries(mainStatShortTexts).find(e => e[1].toLowerCase() === text.toLowerCase())?.at(0) as MainStat | undefined;
}

View File

@@ -1,15 +1,14 @@
import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon';
import { loading } from './components.util';
import { _defer, raw, reactivity, type Proxy, type Reactive } from './reactive';
import { loading } from './components';
import { _defer, raw, reactivity, type Reactive } from './reactive';
export type RedrawableHTML = HTMLElement;
export type Node = HTMLElement | SVGElement | Text | undefined;
export type NodeChildren = Array<Reactive<Node>> | undefined;
export type Class = string | Array<Class> | Record<string, boolean> | undefined;
type Listener<K extends keyof HTMLElementEventMap> = | ((this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any) | {
type Listener<K extends keyof HTMLElementEventMap> = | ((this: HTMLElement, ev: HTMLElementEventMap[K]) => any) | {
options?: boolean | AddEventListenerOptions;
listener: (this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any;
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any;
} | undefined;
export interface DOMList<T> extends Array<T>{
@@ -47,6 +46,7 @@ export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T
{
const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList<U> };
const _cache = new Map<U, Node | Node[] | undefined>();
const seen = new Set<U>(); // Cache pruning utility
if(children)
{
@@ -84,10 +84,14 @@ export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T
else
{
list.forEach(e => {
seen.add(e);
const child = raw(children.render(e, _cache.get(e)));
_cache.set(e, child);
append(element, child);
});
for (const key of _cache.keys()) if (!seen.has(key)) _cache.delete(key);
seen.clear();
}
})
}
@@ -227,19 +231,23 @@ const iconLoadingRegistry: Map<string, Promise<Required<IconifyIcon>> | null | u
export function icon(name: Reactive<string>, properties?: IconProperties)
{
const element = dom('div', { class: properties?.class, style: properties?.style });
let timeout: NodeJS.Timeout = setTimeout(() => {}, 0), target: string;
const build = (icon: IconifyIcon | null | undefined) => {
if(!icon) return element.replaceChildren();
if(!icon) return clearTimeout(timeout) ?? element.replaceChildren();
const built = buildIcon(icon, properties);
const dom = svg('svg', { attributes: built.attributes });
dom.innerHTML = built.body;
clearTimeout(timeout);
element.replaceChildren(dom);
}
reactivity(name, (name) => {
target = name;
if(!iconLoaded(name))
{
element.replaceChildren(loading('small'));
timeout = setTimeout(() => { element.replaceChildren(loading('small')); }, 100);
if(!iconLoadingRegistry.has(name)) iconLoadingRegistry.set(name, loadIcon(name));
iconLoadingRegistry.get(name)?.then(build);
iconLoadingRegistry.get(name)?.then((icon) => target === name && build(icon));
}
else build(getIcon(name));
})

View File

@@ -1,5 +1,5 @@
import { iconLoaded, loadIcon, getIcon } from "iconify-icon";
import type { NodeProperties, Class } from "#shared/dom.util";
import type { NodeProperties, Class } from "#shared/dom";
export type VirtualNode = string;
export function dom<K extends keyof HTMLElementTagNameMap>(tag: K, properties?: NodeProperties, children?: VirtualNode[]): VirtualNode

View File

@@ -2,24 +2,24 @@ import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin
import { Annotation, Compartment, EditorState, Prec, SelectionRange, StateField, type Extension, type Range } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, standardKeymap } from '@codemirror/commands';
import { bracketMatching, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
import { search, searchKeymap } from '@codemirror/search';
import { searchKeymap } from '@codemirror/search';
import { acceptCompletion, autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { IterMode, Tree, type SyntaxNodeRef } from '@lezer/common';
import { tags } from '@lezer/highlight';
import { div, dom, icon, span, type RedrawableHTML } from '#shared/dom.util';
import { div, dom, icon, span } from '~~/shared/dom';
import { callout as calloutExtension, calloutKeymap } from '#shared/grammar/callout.extension';
import { wikilink as wikilinkExtension, autocompletion as wikilinkAutocompletion } from '#shared/grammar/wikilink.extension';
import renderMarkdown from '#shared/markdown.util';
import renderMarkdown from '~~/shared/markdown';
import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout } from "#shared/proses";
import { tagTag, tag as tagExtension } from './grammar/tag.extension';
import { WeakerSet } from './general.util';
import { button, numberpicker } from './components.util';
import { contextmenu, followermenu } from './floating.util';
import { WeakerSet } from './general';
import { button } from './components';
import { followermenu } from './floating';
const External = Annotation.define<boolean>();
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-sm 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 intersects = (a: {
@@ -43,8 +43,8 @@ const highlight = HighlightStyle.define([
{ tag: tags.strong, fontWeight: "bold" },
{ tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: tags.keyword, class: "text-accent-blue" },
{ tag: tags.monospace, class: "border border-light-35 dark:border-dark-35 px-2 py-px rounded-sm bg-light-20 dark:bg-dark-20" },
{ tag: tagTag, class: "cursor-default bg-accent-blue bg-opacity-10 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30" },
{ tag: tags.monospace, class: "border border-light-35 dark:border-dark-35 px-2 py-px rounded-xs bg-light-20 dark:bg-dark-20" },
{ tag: tagTag, class: "cursor-default bg-accent-blue/10 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue/30" },
]);
class CalloutWidget extends WidgetType
@@ -56,7 +56,7 @@ class CalloutWidget extends WidgetType
foldable?: boolean;
content: string;
contentMD: RedrawableHTML;
contentMD: HTMLElement;
static create(node: SyntaxNodeRef, state: EditorState): CalloutWidget | undefined
{
@@ -240,16 +240,19 @@ export class MarkdownEditor
const viewer = MarkdownEditor.viewer;
this.parentElement?.toggleAttribute('data-focused', true);
const follower = followermenu(this, [
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'edit'; follower.close(); } } }, [span('', 'Modif. source'), viewer === 'edit' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'live'; follower.close(); } } }, [span('', 'Modifi. live'), viewer === 'live' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'read'; follower.close(); } } }, [span('', 'Lecture seule'), viewer === 'read' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 hover:dark:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'edit'; follower.close(); } } }, [span('', 'Modif. source'), viewer === 'edit' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 hover:dark:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'live'; follower.close(); } } }, [span('', 'Modifi. live'), viewer === 'live' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 hover:dark:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'read'; follower.close(); } } }, [span('', 'Lecture seule'), viewer === 'read' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
], { class: 'text-light-100 dark:text-dark-100', offset: 0, placement: 'right-start', blur: () => this.parentElement?.toggleAttribute('data-focused', false) });
return follower;
}
constructor()
{
this._dom = div('flex h-full relative', [ div('absolute -top-1 -left-1 -translate-x-px -translate-y-px z-10 group/editor', [ div('group-hover/editor:hidden group-data-[focused]/editor:hidden w-0 h-0 border-8 border-transparent border-l-light-40 dark:border-l-dark-40 border-t-light-40 dark:border-t-dark-40'), button([icon('radix-icons:gear')], MarkdownEditor.settings, 'p-1 hidden group-data-[focused]/editor:block group-hover/editor:block') ]), ]);
this._dom = div('flex h-full relative', [ div('absolute -top-1 -left-1 -translate-x-px -translate-y-px z-10 group/editor', [
div('group-hover/editor:hidden group-data-[focused]/editor:hidden w-0 h-0 border-8 border-transparent border-l-light-40 dark:border-l-dark-40 border-t-light-40 dark:border-t-dark-40'),
button([icon('radix-icons:gear')], MarkdownEditor.settings, 'p-1 hidden! group-data-[focused]/editor:block! group-hover/editor:block!') ]),
]);
this._decoratorVisible = ViewPlugin.fromClass(Decorator, {
decorations: undefined,
}).of(undefined);

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import * as FloatingUI from "@floating-ui/dom";
import { cancelPropagation, dom, svg, text, type Class, type NodeChildren, type RedrawableHTML } from "./dom.util";
import { button } from "./components.util";
import { cancelPropagation, dom, svg, text, type Class, type NodeChildren } from "./dom";
import { button } from "./components";
import type { Reactive } from "./reactive";
export interface FloatingProperties
@@ -10,7 +10,7 @@ export interface FloatingProperties
arrow?: boolean;
class?: Class;
style?: Record<string, string | undefined | boolean | number> | string;
viewport?: RedrawableHTML;
viewport?: HTMLElement;
cover?: 'width' | 'height' | 'all' | 'none';
persistant?: boolean;
}
@@ -41,8 +41,8 @@ export interface ModalProperties
onClose?: () => boolean | void;
}
type ModalInternals = {
container: RedrawableHTML;
content: RedrawableHTML;
container: HTMLElement;
content: HTMLElement;
stop: Function;
start: Function;
show: Function;
@@ -50,7 +50,7 @@ type ModalInternals = {
persistant: boolean;
};
export let teleport: RedrawableHTML, minimizeBox: RedrawableHTML, cache: ModalInternals[] = [], hook: VoidFunction = () => {};
export let teleport: HTMLElement, minimizeBox: HTMLElement, cache: ModalInternals[] = [], hook: VoidFunction = () => {};
export function init()
{
dispose();
@@ -76,12 +76,12 @@ function clear()
cache = cache.filter(e => !(!e.persistant && e.content.remove()));
}
export function popper(container: RedrawableHTML, properties?: PopperProperties)
export function popper(container: HTMLElement, properties?: PopperProperties)
{
let state: FloatState = 'hidden', timeout: Timer;
const arrow = svg('svg', { class: ' group-data-[pinned]:hidden absolute fill-light-35 dark:fill-dark-35', attributes: { width: "12", height: "8", viewBox: "0 0 20 10" } }, [svg('polygon', { attributes: { points: "0,0 20,0 10,10" } })]);
const content = dom('div', { class: properties?.class, style: properties?.style });
const floater = dom('div', { class: 'fixed hidden group', attributes: { 'data-state': 'closed' } }, [ content, properties?.arrow ? arrow : undefined ]);
const floater = dom('div', { class: 'fixed hidden group z-50', attributes: { 'data-state': 'closed' } }, [ content, properties?.arrow ? arrow : undefined ]);
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
function update()
@@ -233,7 +233,7 @@ export function popper(container: RedrawableHTML, properties?: PopperProperties)
clearTimeout(timeout);
}
function link(element: RedrawableHTML) {
function link(element: HTMLElement) {
(properties?.events?.show ?? ['mouseenter', 'mousemove', 'focus']).forEach((e: keyof HTMLElementEventMap) => element.addEventListener(e, show));
(properties?.events?.hide ?? ['mouseleave', 'blur']).forEach((e: keyof HTMLElementEventMap) => element.addEventListener(e, hide));
}
@@ -346,7 +346,7 @@ export function followermenu(target: FloatingUI.ReferenceElement, content: NodeC
properties?.priority || document.addEventListener('mousedown', close);
container.addEventListener('mousedown', cancelPropagation);
const stop = FloatingUI.autoUpdate(target, container, update, {
let stop = FloatingUI.autoUpdate(target, container, update, {
animationFrame: true,
layoutShift: false,
elementResize: false,
@@ -384,7 +384,7 @@ export function contextmenu(x: number, y: number, content: NodeChildren, propert
},
}, content, properties);
}
export function tooltip(container: RedrawableHTML, txt: string | Text, placement: FloatingUI.Placement, delay?: number): RedrawableHTML
export function tooltip(container: HTMLElement, txt: string | Text, placement: FloatingUI.Placement, delay?: number): HTMLElement
{
return popper(container, {
arrow: true,

View File

@@ -73,6 +73,38 @@ export function padRight(text: string, pad: string, length: number): string
{
return pad.repeat(length - text.length).concat(text);
}
export function shallowEquals(a: any, b: any): boolean
{
if(a === b) return true;
if(a && b && typeof a == 'object' && typeof b == 'object')
{
if (a.constructor !== b.constructor) return false;
let i, keys;
if (Array.isArray(a))
{
if (a.length != b.length) return false;
for (i = a.length; i-- !== 0;)
if (!b.includes(a[i])) return false;
return true;
}
if (a.constructor === RegExp) return a.source === b.source && a.flags === b.flags;
if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf();
if (a.toString !== Object.prototype.toString) return a.toString() === b.toString();
keys = Object.keys(a) as Array<keyof typeof a>;
if (keys.length !== Object.keys(b).length) return false;
for (i = keys.length; i-- !== 0;)
if (!Object.prototype.hasOwnProperty.call(b, keys[i]!) || a[keys[i]!] !== b[keys[i]!]) return false;
return true;
}
return a !== a && b !== b;
}
export function deepEquals(a: any, b: any): boolean
{
if(a === b) return true;
@@ -81,12 +113,11 @@ export function deepEquals(a: any, b: any): boolean
{
if (a.constructor !== b.constructor) return false;
let length, i, keys;
let i, keys;
if (Array.isArray(a))
{
length = a.length;
if (length != b.length) return false;
for (i = length; i-- !== 0;)
if (a.length != b.length) return false;
for (i = a.length; i-- !== 0;)
if (!deepEquals(a[i], b[i])) return false;
return true;
}
@@ -96,13 +127,12 @@ export function deepEquals(a: any, b: any): boolean
if (a.toString !== Object.prototype.toString) return a.toString() === b.toString();
keys = Object.keys(a) as Array<keyof typeof a>;
length = keys.length;
if (length !== Object.keys(b).length) return false;
if (keys.length !== Object.keys(b).length) return false;
for (i = length; i-- !== 0;)
for (i = keys.length; i-- !== 0;)
if (!Object.prototype.hasOwnProperty.call(b, keys[i]!)) return false;
for (i = length; i-- !== 0;)
for (i = keys.length; i-- !== 0;)
if(!deepEquals(a[keys[i]!], b[keys[i]!])) return false;
return true;

View File

@@ -2,7 +2,7 @@ import type { MarkdownConfig } from '@lezer/markdown';
import { styleTags, tags } from '@lezer/highlight';
import type { Decoration, EditorView, KeyBinding } from '@codemirror/view';
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state';
import { BlockDecorator } from '../editor.util';
import { BlockDecorator } from '../editor';
export const callout: MarkdownConfig = {
defineNodes: [

View File

@@ -2,7 +2,7 @@ import type { EditorView } from '@codemirror/view';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { Element, MarkdownConfig } from '@lezer/markdown';
import { styleTags, tags } from '@lezer/highlight';
import { Content } from '../content.util';
import { Content } from '../content';
import { selectAll } from 'hast-util-select';
function fuzzyMatch(text: string, search: string): number {

View File

@@ -6,11 +6,11 @@ export type HistoryHandler = {
interface HistoryEvent
{
source: string;
event: string;
actions: HistoryAction[];
}
interface HistoryAction
{
event: string;
element: any;
from?: any;
to?: any;
@@ -47,8 +47,8 @@ export class History
return;
last.actions.forEach(e => {
this.handlers[last.source] && this.handlers[last.source].handlers[last.event]?.undo(e)
this.handlers[last.source] && this.handlers[last.source].any && this.handlers[last.source].any!(e);
this.handlers[last.source] && this.handlers[last.source]?.handlers[e.event]?.undo(e)
this.handlers[last.source] && this.handlers[last.source]?.any && this.handlers[last.source]?.any!(e);
});
this.position--;
@@ -68,18 +68,18 @@ export class History
}
last.actions.forEach(e => {
this.handlers[last.source] && this.handlers[last.source].handlers[last.event]?.redo(e)
this.handlers[last.source] && this.handlers[last.source].any && this.handlers[last.source].any!(e);
this.handlers[last.source] && this.handlers[last.source]?.handlers[e.event]?.redo(e)
this.handlers[last.source] && this.handlers[last.source]?.any && this.handlers[last.source]?.any!(e);
});
}
add(source: string, event: string, actions: HistoryAction[], apply: boolean = false)
add(source: string, actions: HistoryAction[], apply: boolean = false)
{
this.position++;
this.history.splice(this.position, history.length - this.position, { source, event, actions });
this.history.splice(this.position, history.length - this.position, { source, actions });
if(apply)
actions.forEach(e => {
this.handlers[source] && this.handlers[source].handlers[event]?.redo(e);
this.handlers[source] && this.handlers[source].handlers[e.event]?.redo(e);
this.handlers[source] && this.handlers[source].any && this.handlers[source].any(e);
});
}

View File

@@ -1,10 +1,10 @@
import type { Element, Root, RootContent } from "hast";
import { dom, text, type Class, type Node } from "#shared/dom.util";
import { dom, text, type Class, type Node } from "#shared/dom";
import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout, type Prose } from "#shared/proses";
import { heading } from "hast-util-heading";
import { headingRank } from "hast-util-heading-rank";
import { parseId } from "#shared/general.util";
import { async } from "#shared/components.util";
import { parseId } from "#shared/general";
import { async } from "#shared/components";
export const defaultProses = { a, blockquote, tag, callout, h1, h2, h3, h4, h5, hr, li, small, table, td, th };
export function renderMarkdown(markdown: Root, proses: Record<string, Prose>)

View File

@@ -1,6 +1,6 @@
import type { CanvasNode } from "~/types/canvas";
import type { CanvasPreferences } from "~/types/general";
import type { Position, Box, Direction, CanvasEditor } from "./canvas.util";
import type { Position, Box, Direction, CanvasEditor } from "./canvas";
interface SnapPoint {
pos: Position;

View File

@@ -1,10 +1,10 @@
import { dom, icon, type NodeChildren, type Node, div, type Class } from "#shared/dom.util";
import { dom, icon, type NodeChildren, type Node, div, type Class } from "#shared/dom";
import { parseURL } from 'ufo';
import render from "#shared/markdown.util";
import { Canvas } from "#shared/canvas.util";
import { Content, iconByType, type LocalContent } from "#shared/content.util";
import { unifySlug } from "#shared/general.util";
import { async, floater } from "./components.util";
import render from "#shared/markdown";
import { Canvas } from "#shared/canvas";
import { Content, iconByType, type LocalContent } from "#shared/content";
import { unifySlug } from "#shared/general";
import { async, floater } from "./components";
export type CustomProse = (properties: any, children: NodeChildren) => Node;
@@ -19,7 +19,7 @@ export const a: Prose = {
const link = overview ? { name: 'explore-path', params: { path: overview.path }, hash: hash } : href, nav = router.resolve(link);
const element = properties?.navigate ?? true ? dom('a', { class: ['text-accent-blue inline-flex items-center', properties?.class], attributes: { href: nav.href }, listeners: {
const element = properties?.navigate ?? true ? dom('a', { class: [properties?.class, 'text-accent-blue inline-flex items-center'], attributes: { href: nav.href }, listeners: {
'click': (e) => {
e.preventDefault();
router.push(link);
@@ -29,7 +29,7 @@ export const a: Prose = {
...(children ?? []),
overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
])
]) : dom('span', { class: ['cursor-pointer text-accent-blue inline-flex items-center', properties?.class] }, [
]) : dom('span', { class: [properties?.class, 'cursor-pointer text-accent-blue inline-flex items-center'] }, [
...(children ?? []),
overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
]);
@@ -57,7 +57,7 @@ export const preview: Prose = {
const overview = Content.getFromPath(pathname === '' && hash.length > 0 ? unifySlug(router.currentRoute.value.params.path ?? '') : pathname);
const element = dom('span', { class: ['cursor-pointer text-accent-blue inline-flex items-center', properties?.class] }, [
const element = dom('span', { class: [properties?.class, 'cursor-pointer text-accent-blue inline-flex items-center'] }, [
...(children ?? []),
overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
]);
@@ -104,12 +104,12 @@ export const callout: Prose = {
} = properties;
let open = fold;
const container = dom('div', { class: ['callout group overflow-hidden my-4 p-3 ps-4 bg-blend-lighten !bg-opacity-25 border-l-4 inline-block pe-8 bg-light-blue dark:bg-dark-blue', properties?.class], attributes: { 'data-state': fold !== false ? 'closed' : 'open', 'data-type': type } }, [
const container = dom('div', { class: ['callout group overflow-hidden my-4 p-3 ps-4 bg-blend-lighten border-l-4 inline-block pe-8 bg-light-blue/25 dark:bg-dark-blue/25', properties?.class], attributes: { 'data-state': fold !== false ? 'closed' : 'open', 'data-type': type } }, [
dom('div', { class: [{'cursor-pointer': fold !== undefined}, 'flex flex-row items-center justify-start ps-2'], listeners: { click: e => {
container.setAttribute('data-state', open ? 'open' : 'closed');
open = !open;
}}},
[icon(calloutIconByType[type] ?? defaultCalloutIcon, { inline: true, width: 24, height: 24, class: 'w-6 h-6 stroke-2 float-start me-2 flex-shrink-0' }), !!title ? dom('span', { class: 'block font-bold text-start', text: title }) : undefined, fold !== undefined ? icon('radix-icons:caret-right', { height: 24, width: 24, class: 'transition-transform group-data-[state=open]:rotate-90 w-6 h-6 mx-6' }) : undefined
[icon(calloutIconByType[type] ?? defaultCalloutIcon, { inline: true, width: 24, height: 24, class: 'w-6 h-6 stroke-2 float-start me-2 shrink-0' }), !!title ? dom('span', { class: 'block font-bold text-start', text: title }) : undefined, fold !== undefined ? icon('radix-icons:caret-right', { height: 24, width: 24, class: 'transition-transform group-data-[state=open]:rotate-90 w-6 h-6 mx-6' }) : undefined
]),
dom('div', { class: {'overflow-hidden': true, 'group-data-[state=closed]:animate-[collapseClose_0.2s_ease-in-out] group-data-[state=open]:animate-[collapseOpen_0.2s_ease-in-out] group-data-[state=closed]:h-0': fold !== undefined } }, [
dom('div', { class: 'px-2' }, children),
@@ -120,7 +120,7 @@ export const callout: Prose = {
},
}
export const tag: Prose = {
class: "before:content-['#'] 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: "before:content-['#'] cursor-default bg-accent-blue/10 hover:bg-accent-blue/20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue/30",
}
export const blockquote: Prose = {
class: 'empty:before:hidden ps-4 my-4 relative before:absolute before:-top-1 before:-bottom-1 before:left-0 before:w-1 before:bg-light-30 dark:before:bg-dark-30',
@@ -144,7 +144,7 @@ export const hr: Prose = {
class: 'border-b border-light-35 dark:border-dark-35 m-4',
}
export const li: Prose = {
class: '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',
class: 'before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded-sm before:bg-light-40 dark:before:bg-dark-40 relative ps-4',
}
export const small: Prose = {
class: 'text-light-60 dark:text-dark-60 text-sm italic',

View File

@@ -17,7 +17,7 @@ export const _defer = (fn: () => void) => {
_deferSet.add(fn);
}
let activeEffect: (() => void) | null = null, _isTracking = true;
let effectStack: Array<(() => void)> = [], _isTracking = true;
const SYMBOLS = {
PROXY: Symbol('is a proxy'),
ITERATE: Symbol('iterating'),
@@ -26,7 +26,7 @@ const SYMBOLS = {
function reactiveReadArray<T>(array: T[]): T[]
{
const _raw = raw(array)
const _raw = raw(array);
if (_raw === array) return _raw;
track(_raw, SYMBOLS.ITERATE);
return _raw.map(wrapReactive);
@@ -42,7 +42,7 @@ function iterator(self: unknown[], method: keyof Array<unknown>, wrapValue: (val
const iter = (arr[method] as any)() as IterableIterator<unknown> & {
_next: IterableIterator<unknown>['next']
};
if (arr !== self && !isShallow(self))
if (arr !== self)
{
iter._next = iter.next;
iter.next = () => {
@@ -82,7 +82,7 @@ function apply(self: unknown[], method: keyof Array<any>, fn: (item: unknown, in
else if (fn.length > 2)
{
wrappedFn = function (this: unknown, item, index) {
return fn.call(this, item, index, self);
return fn.call(this, wrapReactive(item), index, self);
};
}
}
@@ -95,7 +95,7 @@ function reduce(self: unknown[], method: keyof Array<any>, fn: (acc: unknown, it
let wrappedFn = fn;
if (arr !== self && fn.length > 3)
{
wrappedFn = function (this: unknown, acc, item, index) { return fn.call(this, acc, wrapReactive(item), index, self) };
wrappedFn = function (this: unknown, acc, item, index) { return fn.call(this, acc, wrapReactive(item), index, self) };
}
else
{
@@ -119,14 +119,19 @@ function searchProxy(self: unknown[], method: keyof Array<any>, args: unknown[])
}
function noTracking(self: unknown[], method: keyof Array<any>, args: unknown[] = [])
{
_isTracking = false;
const res = (raw(self) as any)[method].apply(self, args);
_isTracking = true;
return res;
try
{
_isTracking = false;
return (raw(self) as any)[method].apply(self, args);
}
finally
{
_isTracking = true;
}
}
const arraySubstitute = <any>{ // <-- <any> is required to allow __proto__ without getting an error
__proto__: null, // <-- Required to remove the object prototype removing the object default functions from the substitution
__proto__: null, // <-- Required to remove the object prototype, removing the object default functions from the substitution as a result
[Symbol.iterator]() { return iterator(this, Symbol.iterator, item => wrapReactive(item)) },
concat(...args: unknown[]) { return reactiveReadArray(this).concat(...args.map(x => (Array.isArray(x) ? reactiveReadArray(x) : x))) },
entries() { return iterator(this, 'entries', (value: [number, unknown]) => { value[1] = wrapReactive(value[1]); return value; }) },
@@ -192,7 +197,7 @@ function trigger(target: object, key?: string | symbol | null, value?: unknown)
}
function track(target: object, key: string | symbol | null)
{
if(!activeEffect || !_isTracking) return;
if(effectStack.length === 0 || !_isTracking) return;
let dependencies = _tracker.get(target);
if(!dependencies)
@@ -208,9 +213,7 @@ function track(target: object, key: string | symbol | null)
dependencies.set(key, set);
}
set.add(activeEffect);
//if(set) console.log('Tracking %o with key "%s"', target, key, set.size);
set.add(effectStack.slice(-1)[0]!);
}
export type Proxy<T> = T & {
[SYMBOLS.PROXY]?: boolean;
@@ -273,7 +276,12 @@ export function reactive<T extends object>(obj: T | Proxy<T>): T | Proxy<T>
const result = Reflect.ownKeys(target);
track(target, SYMBOLS.ITERATE);
return result;
}
},
defineProperty: (target, property, attributes) => {
const result = Reflect.defineProperty(target, property, attributes);
trigger(target, SYMBOLS.ITERATE);
return result;
},
}) as Proxy<T>;
_reactiveCache.set(obj, proxy);
@@ -289,11 +297,11 @@ export function reactivity<T>(reactiveProperty: Reactive<T>, effect: (processed:
// Also useful to retrigger the tracking system if the reactive property provides new properties (via conditions for example)
const secureEffect = () => effect(typeof reactiveProperty === 'function' ? (reactiveProperty as () => T)() : reactiveProperty);
const secureContext = () => {
activeEffect = secureContext;
effectStack.push(secureContext);
try {
return secureEffect();
} finally {
activeEffect = null;
effectStack.pop();
}
};

View File

@@ -1,6 +1,6 @@
import { Content, type LocalContent } from "./content.util";
import { dom, type RedrawableHTML } from "./dom.util";
import { clamp } from "./general.util";
import { Content, type LocalContent } from "./content";
import { dom } from "./dom";
import { clamp } from "./general";
export type Recursive<T> = T & {
children?: T[];
@@ -138,14 +138,14 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
}
export class TreeDOM
{
container: RedrawableHTML;
tree: Tree<Omit<LocalContent & { element?: RedrawableHTML }, "content">>;
container: HTMLElement;
tree: Tree<Omit<LocalContent & { element?: HTMLElement }, "content">>;
private filter?: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => boolean | undefined;
private folder: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => RedrawableHTML;
private leaf: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => RedrawableHTML;
private folder: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement;
private leaf: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement;
constructor(folder: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => RedrawableHTML, leaf: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => RedrawableHTML, filter?: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => boolean | undefined)
constructor(folder: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement, leaf: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement, filter?: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => boolean | undefined)
{
this.tree = new Tree(Content.tree);
@@ -156,7 +156,7 @@ export class TreeDOM
const elements = this.tree.accumulate(this.render.bind(this));
this.container = dom('div', { class: 'list-none select-none text-light-100 dark:text-dark-100 text-sm ps-2' }, elements);
}
render(item: Recursive<Omit<LocalContent & { element?: RedrawableHTML }, "content">>, depth: number): RedrawableHTML | undefined
render(item: Recursive<Omit<LocalContent & { element?: HTMLElement }, "content">>, depth: number): HTMLElement | undefined
{
if(this.filter && !(this.filter(item, depth) ?? true))
return;
@@ -187,7 +187,7 @@ export class TreeDOM
{
this.container.replaceChildren(...this.tree.flatten.map(e => e.element!));
}
toggle(item?: Omit<LocalContent & { element?: RedrawableHTML }, 'content'>, state?: boolean)
toggle(item?: Omit<LocalContent & { element?: HTMLElement }, 'content'>, state?: boolean)
{
if(item && item.type === 'folder')
{
@@ -202,7 +202,7 @@ export class TreeDOM
});
}
}
opened(item?: Omit<LocalContent & { element?: RedrawableHTML }, 'content'>): boolean | undefined
opened(item?: Omit<LocalContent & { element?: HTMLElement }, 'content'>): boolean | undefined
{
return item ? item.element!.getAttribute('data-state') === 'open' : undefined;
}