33 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
Clément Pons
ce3dbb0d6e Add Trees and Masteries in the feature editor. Add some items. 2026-01-14 22:40:58 +01:00
Clément Pons
796b335b2e Progress on tree features 2026-01-13 17:47:18 +01:00
Clément Pons
f761e44569 Add back Loading Indicator, rework children caching, small visual improvement on character sheet and config management. 2026-01-12 17:48:28 +01:00
Clément Pons
0eaffcaa04 Several small fixes with rendering and floating components 2026-01-06 17:40:01 +01:00
Clément Pons
7021264c11 Add redirect URL when logging in, fix choices for characters not being saved 2026-01-05 17:34:42 +01:00
04534b2530 Mass updates 2026-01-05 11:33:32 +01:00
32b6cf4af7 Fix incorrect tag end position 2025-12-23 12:23:06 +01:00
Clément Pons
e9ffdd58a5 Persistant item/spell panel to avoid filing the reactive tracker. 2025-12-16 18:07:40 +01:00
Clément Pons
78a101b79d Merge branch 'dev' of https://git.peaceultime.com/Peaceultime/obsidian-visualiser into dev 2025-12-16 15:41:35 +01:00
Clément Pons
49691feeee Rework reactivity for array listening 2025-12-16 15:41:12 +01:00
94645f9dbf Fix mail validation 2025-12-15 18:39:33 +01:00
888adc4743 Merge branch 'dev' of https://git.peaceultime.com/peaceultime/obsidian-visualiser into dev 2025-12-10 20:57:57 +01:00
4862181d61 Fix registration email and markdown parser singleton 2025-12-10 20:57:55 +01:00
Clément Pons
323cb0ba7f Reworking reactivity with a proxy/reflect mecanic 2025-12-10 18:05:52 +01:00
Clément Pons
4cd478b47a Change all HTMLElement to RedrawableHTML + package updates 2025-12-10 14:47:38 +01:00
Clément Pons
1b0b9ca7f4 Fix breaklines in character-config and fix DOM reactivity with children updates. 2025-12-09 17:45:29 +01:00
97578132bb Merge branch 'dev' of https://git.peaceultime.com/peaceultime/obsidian-visualiser into dev 2025-12-08 18:50:51 +01:00
cbe4e1d068 Add Item flavoring 2025-12-08 18:50:49 +01:00
118 changed files with 8995 additions and 5309 deletions

View File

@@ -1,25 +1,26 @@
<template>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
<NuxtRouteAnnouncer/>
<TooltipProvider>
<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">
<NuxtPage />
</div>
</NuxtLayout>
</TooltipProvider>
<NuxtLoadingIndicator :throttle="50"/>
<NuxtLayout>
<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>
</div>
</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(() => {
Content.init();
Floating.init();
Toaster.init();
init()
const unmount = useRouter().afterEach((to, from, failure) => {
if(failure) return;
@@ -35,6 +36,7 @@ onBeforeMount(() => {
</script>
<style>
@reference "./assets/css/main.css";
iconify-icon
{
display: inline-block;
@@ -45,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;
}
@@ -117,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;
}
@@ -126,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;
}
@@ -135,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;
}
@@ -144,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;
}
@@ -183,6 +190,10 @@ iconify-icon
@apply text-light-100 dark:text-dark-100;
}
.cm-focused
{
@apply outline-hidden;
}
.cm-editor .cm-content
{
@apply caret-light-100 dark:caret-dark-100;
@@ -195,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 {
@@ -208,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 {
@@ -233,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

@@ -1,7 +1,6 @@
import { Database } from "bun:sqlite";
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from '../db/schema';
import { eq, or, sql } from "drizzle-orm";
let instance: BunSQLiteDatabase<typeof schema> & {
$client: Database;

View File

@@ -18,7 +18,7 @@ interface Parser
}
export default function useMarkdown(): Parser
{
let processor: Processor, processorSync: Processor;
let processor: Processor, processorText: Processor;
const parse = (markdown: string) => {
if (!processor)
@@ -43,14 +43,14 @@ export default function useMarkdown(): Parser
}
const text = (markdown: string) => {
if (!processor)
if (!processorText)
{
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter ]);
processor.use(StripMarkdown, { remove: [ 'comment', 'tag', 'callout' ] });
processor.use(RemarkStringify);
processorText = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter ]);
processorText.use(StripMarkdown, { remove: [ 'comment', 'tag', 'callout' ] });
processorText.use(RemarkStringify);
}
const processed = processor.processSync(markdown);
const processed = processorText.processSync(markdown);
return String(processed);
}

View File

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

View File

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

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,18 +1,14 @@
import { hasPermissions } from "#shared/auth.util";
import { hasPermissions } from "#shared/auth";
export default defineNuxtRouteMiddleware(async (to, from) => {
const { loggedIn, fetch, user } = useUserSession();
const meta = to.meta;
await fetch()
await fetch();
if(!!meta.guestsGoesTo && !loggedIn.value)
if(meta.requiresAuth && !loggedIn.value)
{
return navigateTo(meta.guestsGoesTo);
}
else if(meta.requireAuth && !loggedIn.value)
{
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
return navigateTo({ name: 'user-login', query: { t: encodeURIComponent(to.path) } });
}
else if(!!meta.usersGoesTo && loggedIn.value)
{
@@ -25,13 +21,9 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
else if(!!meta.rights)
{
if(!user.value)
{
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
}
else if(!hasPermissions(user.value.permissions, meta.rights))
{
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
}
}
return;

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,9 +1,9 @@
<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({
guestsGoesTo: '/user/login',
requiresAuth: true,
});
const id = unifySlug(useRoute().params.id ?? '');

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Toaster } from '#shared/components.util';
import { Toaster } from '~~/shared/components';
definePageMeta({
guestsGoesTo: '/user/login',
requiresAuth: true,
});
const { user, loggedIn } = useUserSession();
@@ -39,7 +39,7 @@ function create()
useRequestFetch()('/api/campaign', {
method: 'POST',
body: { name: 'Margooning', public_notes: '', dm_notes: '', settings: {} },
}).then(() => Toaster.add({ duration: 8000, content: 'Campagne créée', type: 'info' })).catch((e) => Toaster.add({ duration: 8000, title: 'Une erreur est survenue', content: e, type: 'error' }))
}).then((result) => Toaster.add({ duration: 8000, content: 'Campagne créée', type: 'info' })).catch((e) => Toaster.add({ duration: 8000, title: 'Une erreur est survenue', content: e, type: 'error' }))
}
</script>
@@ -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,27 +80,13 @@ 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>
</AlertDialogRoot>
</div>
</div>
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="campaign of campaigns.filter(e => e.status === 'ARCHIVED')">
<NuxtLink :to="{ name: 'campaign-id', params: { id: campaign.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2">
<div class="flex flex-row gap-8 ps-4 items-center">
<div class="flex flex-1 flex-col gap-2 justify-center">
<span class="text-lg font-bold group-hover:text-accent-blue">{{ campaign.name }}</span>
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
<div class="flex flex-row flex-1 items-stretch gap-4">
<span class="text-sm">{{ 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>
</NuxtLink>
</div>
</div>
<div v-if="archives && archives.length > 0" class="flex flex-row w-full gap-8 justify-center items-center"><span class="border-t border-light-35 dark:border-dark-35 flex-1"></span><span class="text-lg font-semibold">Archives</span><span class="border-t border-light-35 dark:border-dark-35 flex-1"></span></div>
<div v-if="archives && archives.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
@@ -114,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">
@@ -129,39 +115,25 @@ 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>
</AlertDialogRoot>
</div>
</div>
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="campaign of campaigns.filter(e => e.status === 'ARCHIVED')">
<NuxtLink :to="{ name: 'campaign-id', params: { id: campaign.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2">
<div class="flex flex-row gap-8 ps-4 items-center">
<div class="flex flex-1 flex-col gap-2 justify-center">
<span class="text-lg font-bold group-hover:text-accent-blue">{{ campaign.name }}</span>
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
<div class="flex flex-row flex-1 items-stretch gap-4">
<span class="text-sm">{{ 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>
</NuxtLink>
</div>
</div>
</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,9 +1,10 @@
<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({
guestsGoesTo: '/user/login',
requiresAuth: true,
validState: true,
});
const id = unifySlug(useRouter().currentRoute.value.params.id ?? "new");
const container = useTemplateRef('container');

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,14 +1,16 @@
<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({
guestsGoesTo: '/user/login',
requiresAuth: true,
})
const { data: characters, error, status } = await useFetch(`/api/character`);
const config = characterConfig as CharacterConfig;
const { user } = useUserSession();
async function deleteCharacter(id: number)
{
status.value = "pending";
@@ -49,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>
@@ -68,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>
@@ -78,14 +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 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>
<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" :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-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

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

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { HomebrewBuilder } from '#shared/feature.util';
import { HomebrewBuilder } from '#shared/feature';
definePageMeta({
guestsGoesTo: '/user/login',
requiresAuth: true,
});
const container = useTemplateRef('container');

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,19 +92,26 @@ 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);
editor = new Editor();
Content.ready.then(() => tree.value!.replaceChild(editor.tree.container, load));
Content.ready.then(() => tree.value?.replaceChild(editor.tree.container, load));
container.value.appendChild(editor.container);
}
});
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,11 +26,11 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { Toaster } from '#shared/components.util';
import { Toaster } from '~~/shared/components';
definePageMeta({
layout: 'login',
guestsGoesTo: '/user/login',
requiresAuth: true,
});
const { user } = useUserSession();

View File

@@ -4,10 +4,10 @@
</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">
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
<TextInput type="text" label="Utilisateur ou email" name="username" autocomplete="username email" v-model="state.usernameOrEmail"/>
<TextInput type="password" label="Mot de passe" name="password" autocomplete="current-password" v-model="state.password"/>
<Button type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Se connecter</Button>
@@ -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',
@@ -63,7 +63,10 @@ async function submit()
{
Toaster.clear();
Toaster.add({ duration: 10000, content: 'Vous êtes maintenant connecté', timer: true, type: 'success' });
useRouter().push({ name: 'user-profile' });
const router = useRouter();
const target = router.currentRoute.value.query?.t as string | undefined;
router.push(target ? decodeURIComponent(target) : { name: 'user-profile' });
}
}
else

View File

@@ -1,9 +1,9 @@
<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({
guestsGoesTo: '/user/login',
requiresAuth: true,
})
const { user, clear } = useUserSession();
const loading = ref<boolean>(false);
@@ -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>;

1
app/types/auth.d.ts vendored
View File

@@ -7,7 +7,6 @@ declare module 'vue-router'
interface RouteMeta
{
requiresAuth?: boolean;
guestsGoesTo?: string;
usersGoesTo?: string;
rights?: string[];
validState?: boolean;

View File

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

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 } 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,15 +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;
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 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?: any;
state?: (ArmorState | WeaponState | WondrousState | MundaneState) & CommonState;
buffer?: Record<string, PropertySum>
};
export type CharacterConfig = {
peoples: Record<string, RaceConfig>;
@@ -71,36 +95,50 @@ export type CharacterConfig = {
spells: Record<string, SpellConfig>;
aspects: Record<string, AspectConfig>;
features: Record<FeatureID, Feature>;
enchantments: Record<string, EnchantementConfig>; //TODO
improvements: Record<string, ImprovementConfig>;
items: Record<string, ItemConfig>;
sickness: Record<string, { id: string, name: string, description: string, effect: FeatureID[] }>;
action: Record<string, { id: string, name: string, description: string, cost: number }>;
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
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;
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 = {
id: string;
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, difficulty?: number, ability?: CraftingType };
variants?: string[]; //ID array
};
type ArmorConfig = {
category: 'armor';
health: number;
@@ -127,7 +165,7 @@ 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;
@@ -155,37 +193,50 @@ 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: 'weapon/damage/value' | 'armor/health' | 'armor/absorb/flat' | 'armor/absorb/percent';
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";
list: "spells" | "sickness" | "action" | "reaction" | "freeaction" | "passive" | "mastery" | "poison" | "dedication";
action: "add" | "remove";
item: string;
};
export type FeatureTree = {
id: FeatureEffectID;
category: "tree";
tree: string;
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
amount: number;
exclusive: boolean; //Disallow to pick the same option twice
};
options: Array<{ text: string, effects: Array<FeatureValue | FeatureList> }>; //TODO -> TextID
options: Array<{ text: string, effects: Array<FeatureValue | FeatureList | FeatureTree> }>; //TODO -> TextID
};
export type FeatureItem = FeatureValue | FeatureList | FeatureChoice;
export type FeatureItem = FeatureValue | FeatureState | FeatureList | FeatureChoice | FeatureTree;
export type Feature = {
id: FeatureID;
description: string; //TODO -> TextID
@@ -203,16 +254,22 @@ export type CompiledCharacter = {
spellslots: number; //Max
artslots: number; //Max
spellranks: Record<SpellType, 0 | 1 | 2 | 3>;
aspect: string; //ID
aspect: {
id: string,
amount: number;
duration: number;
shift_bonus: number;
tier: 0 | 1 | 2;
bonus?: Partial<CompiledCharacter['bonus']>;
};
mastery: Array<`weapon/${WeaponType}` | `armor/${'light' | 'medium' | 'heavy'}`>;
speed: number | false;
capacity: number | false;
initiative: number;
exhaust: number;
itempower: number;
action: number;
reaction: number;
variables: CharacterVariables,
defense: {
@@ -224,28 +281,25 @@ export type CompiledCharacter = {
passivedodge: number;
};
mastery: {
strength: number;
dexterity: number;
shield: number;
armor: number;
multiattack: number;
magicpower: number;
magicspeed: number;
magicelement: number;
magicinstinct: number;
};
bonus: {
defense: Partial<Record<MainStat, number>>;
defense: Partial<Record<MainStat, number>>; //Defense aux jets de resistance
abilities: Partial<Record<Ability, number>>;
spells: {
type: Partial<Record<SpellType | 'arts', number>>;
rank: Partial<Record<1 | 2 | 3 | 4, number>>;
elements: Partial<Record<SpellElement, number>>;
};
weapon: Partial<Record<WeaponType, number>>;
resistance: Partial<Record<Resistance, number>>; //Bonus à l'attaque
damage: Partial<DamageType, 'resistance' | 'immunity' | 'vulnerability'>;
}; //Any special bonus goes here
resistance: Record<string, number>;
craft: { level: number, bonus: number };
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 };
};

View File

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

2318
bun.lock

File diff suppressed because it is too large Load Diff

BIN
db.sqlite

Binary file not shown.

View File

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

View File

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

View File

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

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,7 +101,7 @@ 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: {
@@ -187,15 +124,62 @@ export default defineNuxtConfig({
}
},
vite: {
plugins: [
tailwindcss(),
],
server: {
hmr: {
protocol: 'wss',
host: 'localhost',
port: 3000,
clientPort: 3000,
}
}
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: {
isCustomElement: (tag) => tag === 'iconify-icon',
}
},
devtools: {
enabled: false,
}
})

View File

@@ -9,31 +9,32 @@
"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.7",
"@nuxtjs/tailwindcss": "^6.14.0",
"@nuxtjs/sitemap": "^8.1.0",
"@tailwindcss/vite": "^4.1.18",
"@vueuse/gesture": "^2.0.0",
"@vueuse/math": "^14.0.0",
"@vueuse/nuxt": "^14.0.0",
"@vueuse/math": "^14.3.0",
"@vueuse/nuxt": "^14.3.0",
"codemirror": "^6.0.2",
"drizzle-orm": "^0.44.7",
"drizzle-orm": "^0.45.2",
"hast": "^1.0.0",
"hast-util-heading": "^3.0.0",
"hast-util-heading-rank": "^3.0.0",
"hast-util-select": "^6.0.4",
"iconify-icon": "^3.0.2",
"lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.2",
"nodemailer": "^7.0.10",
"nuxt": "^4.2.1",
"nuxt-security": "^2.4.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",
@@ -42,23 +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.24",
"vue-router": "^4.6.3",
"zod": "^4.1.12"
"unist-util-visit": "^5.1.0",
"vue": "^3.5.35",
"vue-router": "^5.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/bun": "^1.3.2",
"@types/bun": "^1.3.14",
"@types/lodash.capitalize": "^4.2.9",
"@types/nodemailer": "^7.0.3",
"@types/nodemailer": "^8.0.0",
"@types/unist": "^3.0.3",
"better-sqlite3": "^12.4.1",
"bun-types": "^1.3.2",
"drizzle-kit": "^0.31.6",
"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

@@ -74,7 +74,7 @@ export default defineEventHandler(async (e): Promise<Return> => {
logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [], lastTimestamp: new Date() } }) as UserSessionRequired);
const emailId = Bun.hash('register' + id.id + hash, Date.now());
const emailId = Bun.hash('register' + id.id + hash, Date.now()).toString(10);
const timestamp = Date.now() + 1000 * 60 * 60;
await runTask('validation', {
@@ -90,7 +90,7 @@ export default defineEventHandler(async (e): Promise<Return> => {
template: 'registration',
data: {
id: emailId, timestamp,
userId: id,
userId: id.id,
username: body.data.username,
},
}

View File

@@ -24,7 +24,7 @@ export default defineEventHandler(async (e) => {
if(result && result.id)
{
const id = hash('reset' + result.id + result.hash, Date.now());
const id = hash('reset' + result.id + result.hash, Date.now()).toString(10);
const timestamp = Date.now() + 1000 * 60 * 60;
await runTask('validation', {
payload: {

View File

@@ -2,15 +2,15 @@ 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);
if(!body.success)
{
setResponseStatus(e, 400);
return body.error.message;
throw body.error.message;
}
const session = await getUserSession(e);
@@ -39,7 +39,7 @@ export default defineEventHandler(async (e) => {
});
setResponseStatus(e, 201);
return id;
return { id, link: cryptURI('campaign', id) };
}
catch(_e)
{

View File

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

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

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) => {
@@ -62,6 +62,9 @@ export default defineEventHandler(async (e) => {
const abilities = Object.entries(body.data.abilities).filter(e => e[1] !== undefined).map(e => ({ character: id, ability: e[0] as Ability, value: e[1], max: 0 }));
if(abilities.length > 0) tx.insert(characterAbilitiesTable).values(abilities).run();
const choices = Object.entries(body.data.choices).flatMap(e => e[1].map(_e => ({ character: id, id: e[0], choice: _e })));
if(choices.length > 0) tx.insert(characterChoicesTable).values(choices).run();
});
setResponseStatus(e, 200);

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,20 +0,0 @@
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const session = await getUserSession(e);
if(!session.user)
{
setResponseStatus(e, 401);
return;
}
setResponseStatus(e, 200);
return {};
});

View File

@@ -1,20 +0,0 @@
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const session = await getUserSession(e);
if(!session.user)
{
setResponseStatus(e, 401);
return;
}
setResponseStatus(e, 200);
return;
});

View File

@@ -47,7 +47,7 @@ export default defineEventHandler(async (e) => {
return;
}
const emailId = hash('register' + data.id + data.hash, Date.now());
const emailId = hash('register' + data.id + data.hash, Date.now()).toString(10);
const timestamp = Date.now() + 1000 * 60 * 60;
await runTask('validation', {

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,4 +1,5 @@
import { dom, text } from "#shared/dom.virtual.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

@@ -16,6 +16,8 @@ export default defineEventHandler(async (e) => {
if(!query.success)
throw query.error;
const date = new Date();
if(Bun.hash('1' + query.data.u.toString(), query.data.t).toString() !== query.data.h)
{
return createError({
@@ -23,7 +25,7 @@ export default defineEventHandler(async (e) => {
message: 'Lien incorrect',
});
}
if(Date.now() > query.data.t + (60 * 60 * 1000))
if(date.getTime() > query.data.t + (60 * 60 * 1000))
{
return createError({
statusCode: 400,
@@ -34,7 +36,7 @@ export default defineEventHandler(async (e) => {
const db = useDatabase();
const validate = db.select(getTableColumns(emailValidationTable)).from(emailValidationTable).where(eq(emailValidationTable.id, query.data.i)).get();
if(!validate || validate.timestamp <= new Date())
if(!validate || validate.timestamp <= date)
{
return createError({
statusCode: 400,
@@ -42,7 +44,7 @@ export default defineEventHandler(async (e) => {
});
}
db.delete(emailValidationTable).where(lte(emailValidationTable.timestamp, new Date())).run();
db.delete(emailValidationTable).where(lte(emailValidationTable.timestamp, date)).run();
const result = db.select({ state: usersTable.state }).from(usersTable).where(eq(usersTable.id, query.data.u)).get();
if(result === undefined)

View File

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

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

@@ -14,18 +14,18 @@ export default defineTask({
name: 'validation',
description: 'Add email ID to DB',
},
run(e) {
run({ payload, context }) {
try {
if(e.payload.type !== 'validation')
if(payload.type !== 'validation')
{
throw new Error(`Données inconnues`);
}
const payload = e.payload as ValidationPayload;
const _payload = payload as ValidationPayload;
const db = useDatabase();
db.delete(emailValidationTable).where(lt(emailValidationTable.timestamp, new Date())).run();
db.insert(emailValidationTable).values({ id: payload.id, timestamp: new Date(payload.timestamp) }).run();
db.insert(emailValidationTable).values({ id: _payload.id, timestamp: new Date(_payload.timestamp) }).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));
}

314
shared/campaign.ts Normal file
View File

@@ -0,0 +1,314 @@
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 } 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";
import { reactive } from "#shared/reactive";
import type { Character, CharacterConfig } from "~/types/character";
import { MarkdownEditor } from "./editor";
import { getText } from "./i18n";
const config = characterConfig as CharacterConfig;
export const CampaignValidation = z.object({
id: z.number(),
name: z.string().nonempty(),
public_notes: z.string(),
dm_notes: z.string(),
settings: z.object(),
});
class CharacterPrinter
{
compiler?: CharacterCompiler;
container: HTMLElement;
name: string;
id: number;
constructor(character: number, name: string)
{
this.id = character;
this.name = name;
this.container = div('flex flex-col gap-2 px-1', [ div('flex flex-row justify-between items-center', [ span('text-bold text-xl', name), loading('small') ]) ]);
useRequestFetch()(`/api/character/${character}`).then((character) => {
if(character)
{
this.compiler = new CharacterCompiler(character);
const compiled = this.compiler.compiled;
const armor = this.compiler.armor;
this.container.replaceChildren(div('flex flex-row justify-between items-center', [
span('text-bold text-xl', compiled.name),
div('flex flex-row gap-2 items-baseline', [ span('text-sm font-bold', 'PV'), span('text-lg', `${(compiled.health ?? 0) - (compiled.variables.health ?? 0)}/${compiled.health ?? 0}`) ]),
]), div('flex flex-row justify-between items-center', [
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Armure'), span('text-lg', armor === undefined ? `-` : `${armor.current}/${armor.max}`) ]),
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Mana'), span('text-lg', `${(compiled.mana ?? 0) - (compiled.variables.mana ?? 0)}/${compiled.mana ?? 0}`) ]),
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Fatigue'), span('text-lg', `${compiled.variables.exhaustion ?? 0}`) ]),
]));
}
else throw new Error();
}).catch((e) => {
console.error(e);
this.container.replaceChildren(span('text-sm italic text-light-red dark:text-dark-red', 'Données indisponible'));
})
}
}
export class CampaignSheet
{
private user: ComputedRef<User | null>;
private campaign?: Campaign;
private characters!: Array<CharacterPrinter>;
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>)
{
this.user = user;
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
this.container.replaceChildren(load);
useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
if(campaign)
{
this.campaign = reactive(campaign);
this.characters = reactive(campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name)));
/* this.ws = new Socket(`/ws/campaign/${id}`, true);
this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('character', (character) => {
if(character.action === 'ADD')
{
this.characters.push(new CharacterPrinter(character.id, character.name));
}
else if(character.action === 'REMOVE')
{
const idx = this.characters.findIndex(e => e.compiler?.character.id !== character.id);
idx !== -1 && this.characters.splice(idx, 1);
}
});
this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('player', (player) => {
if(player.action === 'ADD')
{
this.campaign?.members.push({ member: { id: player.id, username: player.name } });
}
else if(player.action === 'REMOVE')
{
const idx = this.campaign?.members.findIndex(e => e.member.id !== player.id);
idx && idx !== -1 && this.characters.splice(idx, 1);
}
});
this.ws.handleMessage<void>('hardsync', () => {
useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
this.campaign = reactive(campaign);
this.characters = reactive(campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name)));
});
}); */
document.title = `d[any] - Campagne ${campaign.name}`;
this.render();
load.remove();
}
else throw new Error();
}).catch((e) => {
console.error(e);
this.container.replaceChildren(div('flex flex-col items-center justify-center flex-1 h-full gap-4', [
span('text-2xl font-bold tracking-wider', 'Campagne introuvable'),
span(undefined, 'Cette campagne n\'existe pas ou est privé.'),
div('flex flex-row gap-4 justify-center items-center', [
button(text('Mes campagnes'), () => useRouter().push({ name: 'campaign' }), 'px-2 py-1'),
button(text('Créer une campagne'), () => useRouter().push({ name: 'campaign-create' }), 'px-2 py-1')
])
]));
});
}
save()
{
if(!this.campaign)
return;
return useRequestFetch()(`/api/campaign/${this.campaign.id}`, {
method: 'POST',
body: {
name: this.campaign.name,
status: this.campaign.status,
public_notes: this.campaign.public_notes,
dm_notes: this.campaign.dm_notes,
},
}).then(() => {}).catch(() => {
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
});
}
saveVariables()
{
}
private render()
{
const campaign = this.campaign;
if(!campaign)
return;
const charPicker = this.characterPicker();
this.container.replaceChildren(div('grid grid-cols-3 gap-2', [
div('flex flex-row gap-2 items-center py-2', [
div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), campaign.owner.username, 'bottom') ]),
div('border-l h-full w-0 border-light-40 dark:border-dark-40'),
div('flex flex-row gap-1', { list: campaign.members, render: (member, _c) => _c ?? div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), member.member.username, 'bottom') ]) }),
]),
div('flex flex-1 flex-col items-center justify-center gap-2', [
span('text-2xl font-serif font-bold italic', campaign.name),
span('italic text-light-70 dark:text-dark-70 text-sm', campaign.status === 'PREPARING' ? 'En préparation' : campaign.status === 'PLAYING' ? 'En jeu' : 'Archivé'),
]),
div('flex flex-1 flex-col items-center justify-center', [
div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [
dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]),
button(icon(() => 'radix-icons:clipboard', { width: 16, height: 16 }), () => {}, 'p-1'),
]),
]),
]),
div('flex flex-row gap-4 flex-1 h-0', [
div('flex flex-col gap-2', [
div('flex flex-row items-center gap-4 w-[320px]', [ span('font-bold text-lg', 'Etat'), div('border-t border-light-40 dark:border-dark-40 border-dashed flex-1') ]),
div('flex flex-col gap-2', { list: this.characters, render: (e, _c) => _c ?? e.container }),
div('px-8 py-4 w-full flex', [
button([
icon('radix-icons:plus-circled', { width: 24, height: 24 }),
span('text-sm', 'Ajouter un personnage'),
], () => charPicker.show(), 'flex flex-col flex-1 gap-2 p-4 items-center justify-center text-light-60 dark:text-dark-60'),
])
]),
div('flex h-full border-l border-light-40 dark:border-dark-40'),
div('flex flex-col', [
tabgroup([
{ id: 'campaign', title: [ text('Campagne') ], content: () => {
const editor = new MarkdownEditor();
editor.content = campaign.public_notes;
editor.onChange = (v) => campaign.public_notes = v;
return [
this.user.value && this.user.value.id === campaign.owner.id ? div('flex flex-col gap-4 p-1', [ div('flex flex-row justify-between items-center', [ span('text-xl font-bold', 'Notes destinées aux joueurs'), div('flex flex-row gap-2', [ tooltip(button(icon('radix-icons:paper-plane', { width: 16, height: 16 }), () => this.save(), 'p-1 items-center justify-center'), 'Enregistrer', 'right') ]) ]), div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 h-64', [editor.dom])]) : markdown(campaign.public_notes, '', { tags: { a: preview }, class: 'px-2' }),
];
} },
{ id: 'inventory', title: [ text('Inventaire') ], content: () => this.items() },
{ id: 'settings', title: [ text('Paramètres') ], content: () => [
] },
{ id: 'ressources', title: [ text('Ressources') ], content: () => [
] }
], { focused: 'campaign', class: { container: 'max-w-[900px] w-[900px] h-full', content: 'overflow-auto p-2', tabbar: 'gap-4 border-b border-light-30 dark:border-dark-30' } }),
])
]))
}
characterPicker()
{
const current = reactive({
characters: [] as Character[],
loading: true,
});
const _modal = modal([
div('flex flex-col gap-4 items-center min-w-[480px] min-h-24', [
span('text-xl font-bold', 'Mes personnages'),
div('grid grid-cols-3 gap-2', { list: () => current.characters, render: (e, _c) => _c ?? div('border border-light-40 dark:border-dark-40 p-2 flex flex-col w-[140px]', [
span('font-bold', e.name),
span('', `Niveau ${e.level}`),
button(text('Ajouter'), () => useRequestFetch()(`/api/character/${e.id}/campaign/${this.campaign!.id}`, { method: 'POST' }).then(() => this.ws!.send('character', { id: e.id, name: e.name, action: 'ADD', })).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(_modal.close)),
]), fallback: () => div('flex justify-center items-center col-span-3', [current.loading ? loading('large') : span('text-light-60 dark:text-dark-60 text-sm italic', 'Vous n\'avez pas de personnage disponible')]) }),
]),
], { closeWhenOutside: true, priority: true, class: { container: 'max-w-[560px]' }, open: false });
return { show: () => {
current.loading = true;
useRequestFetch()(`/api/character`).then((list) => {
current.characters = list?.filter(e => !this.characters.find(_e => _e.compiler?.character.id === e.id)) ?? [];
}).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(() => {
current.loading = false;
});
_modal.open();
}, hide: _modal.close }
}
items()
{
if(!this.campaign)
return [];
const items = this.campaign.items;
/* const money = {
readonly: dom('div', { listeners: { click: () => { money.readonly.replaceWith(money.edit); money.edit.focus(); } }, class: 'cursor-pointer border border-transparent hover:border-light-40 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 }) ]) */text("TODO") ]),
])
]),
div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: this.campaign.items, render: (e, _c) => {
if(_c) return _c;
const item = config.items[e.id];
if(!item) return;
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}` : '-') ]);
return foldable(() => [
markdown(getText(item.description)),
div('flex flex-row justify-center gap-1', [
button(text('Offrir'), () => {
}, 'px-2 text-sm h-5 box-content'),
button(icon('radix-icons:minus', { width: 12, height: 12 }), () => {
const idx = items.findIndex(_e => _e === e);
if(idx === -1) return;
items[idx]!.amount--;
if(items[idx]!.amount <= 0) items.splice(idx, 1);
this.saveVariables();
}, 'p-1'),
button(icon('radix-icons:plus', { width: 12, height: 12 }), () => {
const idx = items.findIndex(_e => _e === e);
if(idx === -1) return;
if(item.equippable) items.push(stateFactory(item));
else if(items.find(_e => _e === e)) items.find(_e => _e === e)!.amount++;
else items.push(stateFactory(item));
this.saveVariables();
}, 'p-1')
]) ], [div('flex flex-row justify-between', [
div('flex flex-row items-center gap-4', [div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]),]),
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [
e.amount > 1 && !!item.price ? tooltip(price, `Prix unitaire: ${item.price}`, 'bottom') : price,
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('radix-icons:cross-2', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => e.amount ?? '-') ]),
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'text-red': !!item.capacity && itempower() > item.capacity }), () => item.capacity ? `${itempower()}/${item.capacity ?? 0}` : '-') ]),
e.amount > 1 && !!item.weight ? tooltip(weight, `Poids unitaire: ${item.weight}`, 'bottom') : weight,
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => item.charge ? `${item.charge}` : '-') ]),
]),
])], { open: false, class: { icon: 'px-2', container: 'p-1 gap-2', content: 'px-4 pb-1 flex flex-col' } })
}})
])
];
}
settings()
{
}
}

View File

@@ -1,277 +0,0 @@
import { z } from "zod/v4";
import type { User } from "~/types/auth";
import type { Campaign, CampaignLog } from "~/types/campaign";
import { div, dom, icon, span, svg, text } from "#shared/dom.util";
import { button, loading, tabgroup, Toaster } from "#shared/components.util";
import { CharacterCompiler } from "#shared/character.util";
import { modal, tooltip } from "#shared/floating.util";
import markdown from "#shared/markdown.util";
import { preview } from "#shared/proses";
import { format } from "#shared/general.util";
import { Socket } from "#shared/websocket.util";
export const CampaignValidation = z.object({
id: z.number(),
name: z.string().nonempty(),
public_notes: z.string(),
dm_notes: z.string(),
settings: z.object(),
});
class CharacterPrinter
{
compiler?: CharacterCompiler;
container: HTMLElement = div('flex flex-col gap-2 px-1');
constructor(character: number, name: string)
{
this.container.replaceChildren(div('flex flex-row justify-between items-center', [ span('text-bold text-xl', name), loading('small')]));
useRequestFetch()(`/api/character/${character}`).then((character) => {
if(character)
{
this.compiler = new CharacterCompiler(character);
const compiled = this.compiler.compiled;
const armor = this.compiler.armor;
this.container.replaceChildren(div('flex flex-row justify-between items-center', [
span('text-bold text-xl', compiled.name),
div('flex flex-row gap-2 items-baseline', [ span('text-sm font-bold', 'PV'), span('text-lg', `${(compiled.health ?? 0) - (compiled.variables.health ?? 0)}/${compiled.health ?? 0}`) ]),
]), div('flex flex-row justify-between items-center', [
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Armure'), span('text-lg', armor === undefined ? `-` : `${armor.current}/${armor.max}`) ]),
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Mana'), span('text-lg', `${(compiled.mana ?? 0) - (compiled.variables.mana ?? 0)}/${compiled.mana ?? 0}`) ]),
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Fatigue'), span('text-lg', `${compiled.variables.exhaustion ?? 0}`) ]),
]));
}
else throw new Error();
}).catch((e) => {
console.error(e);
this.container.replaceChildren(span('text-sm italic text-light-red dark:text-dark-red', 'Données indisponible'));
})
}
}
type PlayerState = {
statusDOM: HTMLElement;
statusTooltip: Text;
dom: HTMLElement;
user: { id: number, username: string };
};
const logType: Record<CampaignLog['type'], string> = {
CHARACTER: ' a rencontré ',
FIGHT: ' a affronté ',
ITEM: ' a obtenu ',
PLACE: ' est arrivé ',
TEXT: ' ',
}
const activity = {
online: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-green dark:bg-dark-green border-light-green dark:border-dark-green', text: 'En ligne' },
afk: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-yellow dark:bg-dark-yellow border-light-yellow dark:border-dark-yellow', text: 'Inactif' },
offline: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content border-dashed border-light-50 dark:border-dark-50 bg-light-0 dark:bg-dark-0', text: 'Hors ligne' },
}
function defaultPlayerState(user: { id: number, username: string }): PlayerState
{
const statusTooltip = text(activity.offline.text), statusDOM = span(activity.offline.class);
return {
statusDOM,
statusTooltip,
dom: div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), user.username, 'bottom'), tooltip(statusDOM, statusTooltip, 'bottom') ]),
user
}
}
export class CampaignSheet
{
private user: ComputedRef<User | null>;
private campaign?: Campaign;
container: HTMLElement = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6');
private dm!: PlayerState;
private players!: Array<PlayerState>;
private characters!: Array<CharacterPrinter>;
private characterList!: HTMLElement;
private tab: string = 'campaign';
ws?: Socket;
constructor(id: string, user: ComputedRef<User | null>)
{
this.user = user;
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
this.container.replaceChildren(load);
useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
if(campaign)
{
this.campaign = campaign;
this.dm = defaultPlayerState(campaign.owner);
this.players = campaign.members.map(e => defaultPlayerState(e.member));
this.characters = campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name));
this.ws = new Socket(`/ws/campaign/${id}`, true);
this.ws.handleMessage<{ user: number, status: boolean }[]>('status', (users) => {
users.forEach(user => {
if(this.dm.user.id === user.user)
{
this.dm.statusTooltip.textContent = activity[user.status ? 'online' : 'offline'].text;
this.dm.statusDOM.className = activity[user.status ? 'online' : 'offline'].class;
}
else
{
const player = this.players.find(e => e.user.id === user.user)
if(player)
{
player.statusTooltip.textContent = activity[user.status ? 'online' : 'offline'].text;
player.statusDOM.className = activity[user.status ? 'online' : 'offline'].class;
}
}
})
});
this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('character', (character) => {
if(character.action === 'ADD')
{
const printer = new CharacterPrinter(character.id, character.name);
this.characters.push(printer);
this.characterList.appendChild(printer.container);
}
else if(character.action === 'REMOVE')
{
const idx = this.characters.findIndex(e => e.compiler?.character.id !== character.id);
if(idx !== -1)
{
this.characters[idx]!.container.remove();
this.characters.splice(idx, 1);
}
}
});
this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('player', () => {
this.render();
});
this.ws.handleMessage<void>('hardsync', () => {
this.render();
});
document.title = `d[any] - Campagne ${campaign.name}`;
this.render();
load.remove();
}
else throw new Error();
}).catch((e) => {
console.error(e);
this.container.replaceChildren(div('flex flex-col items-center justify-center flex-1 h-full gap-4', [
span('text-2xl font-bold tracking-wider', 'Campagne introuvable'),
span(undefined, 'Cette campagne n\'existe pas ou est privé.'),
div('flex flex-row gap-4 justify-center items-center', [
button(text('Mes campagnes'), () => useRouter().push({ name: 'campaign' }), 'px-2 py-1'),
button(text('Créer une campagne'), () => useRouter().push({ name: 'campaign-create' }), 'px-2 py-1')
])
]));
});
}
private logText(log: CampaignLog)
{
return `${log.target === 0 ? 'Le groupe' : this.players.find(e => e.user.id === log.target)?.user.username ?? 'Un personange'}${logType[log.type]}${log.details}`;
}
private render()
{
const campaign = this.campaign;
if(!campaign)
return;
this.characterList = div('flex flex-col gap-2', this.characters.map(e => e.container));
this.container.replaceChildren(div('grid grid-cols-3 gap-2', [
div('flex flex-row gap-2 items-center py-2', [
this.dm.dom,
div('border-l h-full w-0 border-light-40 dark:border-dark-40'),
div('flex flex-row gap-1', this.players.map(e => e.dom)),
]),
div('flex flex-1 flex-col items-center justify-center gap-2', [
span('text-2xl font-serif font-bold italic', campaign.name),
span('italic text-light-70 dark:text-dark-70 text-sm', campaign.status === 'PREPARING' ? 'En préparation' : campaign.status === 'PLAYING' ? 'En jeu' : 'Archivé'),
]),
div('flex flex-1 flex-col items-center justify-center', [
div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [
dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]),
button(icon('radix-icons:clipboard', { width: 16, height: 16 }), () => {}, 'p-1'),
]),
]),
]),
div('flex flex-row gap-4 flex-1 h-0', [
div('flex flex-col gap-2', [
div('flex flex-row items-center gap-4 w-[320px]', [ span('font-bold text-lg', 'Etat'), div('border-t border-light-40 dark:border-dark-40 border-dashed flex-1') ]),
this.characterList,
div('px-8 py-4 w-full flex', [
button([
icon('radix-icons:plus-circled', { width: 24, height: 24 }),
span('text-sm', 'Ajouter un personnage'),
], () => {
const load = loading('normal');
let characters: HTMLElement[] = [];
const close = modal([
div('flex flex-col gap-4 items-center min-w-[480px] min-h-24', [
span('text-xl font-bold', 'Mes personnages'),
load,
]),
], { closeWhenOutside: true, priority: true, class: { container: 'max-w-[560px]' } }).close;
useRequestFetch()(`/api/character`).then((list) => {
characters = list?.map(e => div('border border-light-40 dark:border-dark-40 p-2 flex flex-col w-[140px]', [
span('font-bold', e.name),
span('', `Niveau ${e.level}`),
button(text('Ajouter'), () => useRequestFetch()(`/api/character/${e.id}/campaign/${this.campaign!.id}`, { method: 'POST' }).then(() => this.ws!.send('character', { id: e.id, name: e.name, action: 'ADD', })).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(close)),
])) ?? [];
}).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(() => {
load.replaceWith(div('grid grid-cols-3 gap-2', characters.length > 0 ? characters : [span('text-light-60 dark:text-dark-60 text-sm italic', 'Vous n\'avez pas de personnage disponible')]));
});
}, 'flex flex-col flex-1 gap-2 p-4 items-center justify-center text-light-60 dark:text-dark-60'),
])
]),
div('flex h-full border-l border-light-40 dark:border-dark-40'),
div('flex flex-col', [
tabgroup([
{ id: 'campaign', title: [ text('Campagne') ], content: () => [
markdown(campaign.public_notes, '', { tags: { a: preview }, class: 'px-2' }),
] },
{ id: 'inventory', title: [ text('Inventaire') ], content: () => [
] },
{ id: 'logs', title: [ text('Logs') ], content: () => {
let lastDate: Date = new Date(0);
const logs = campaign.logs.flatMap(e => {
const date = new Date(e.timestamp), arr = [];
if(Math.floor(lastDate.getTime() / 86400000) < Math.floor(date.getTime() / 86400000))
{
lastDate = date;
arr.push(div('flex flex-row gap-2 items-center relative -left-2 mx-px', [
div('w-3 h-3 border-2 rounded-full bg-light-40 dark:bg-dark-40 border-light-0 dark:border-dark-0'),
div('flex flex-row gap-2 items-center flex-1', [
div('flex-1 border-t border-light-40 dark:border-dark-40 border-dashed'),
span('text-light-70 dark:text-dark-70 text-sm italic tracking-tight', format(date, 'dd MMMM yyyy')),
div('flex-1 border-t border-light-40 dark:border-dark-40 border-dashed'),
])
]))
}
arr.push(div('flex flex-row gap-2 items-center relative -left-2 mx-px group', [
div('w-3 h-3 border-2 rounded-full bg-light-40 dark:bg-dark-40 border-light-0 dark:border-dark-0'),
div('flex flex-row items-center', [ svg('svg', { class: 'fill-light-40 dark:fill-dark-40', attributes: { width: "8", height: "12", viewBox: "0 0 6 9" } }, [svg('path', { attributes: { d: "M0 4.5L6 0L6 9L0 4.5Z" } })]), span('px-4 py-2 bg-light-25 dark:bg-dark-25 border border-light-40 dark:border-dark-40', this.logText(e)) ]),
span('italic text-xs tracking-tight text-light-70 dark:text-dark-70 font-mono invisible group-hover:visible', format(new Date(e.timestamp), 'HH:mm:ss')),
]));
return arr;
});
return [
campaign.logs.length > 0 ? div('flex flex-row ps-12 py-4', [
div('border-l-2 border-light-40 dark:border-dark-40 relative before:absolute before:block before:border-[6px] before:border-b-[12px] before:-left-px before:-translate-x-1/2 before:border-transparent before:border-b-light-40 dark:before:border-b-dark-40 before:-top-3'),
div('flex flex-col-reverse gap-8 py-4', logs),
]) : div('flex py-4 px-16', [ span('italic text-light-70 dark:text-darl-70', 'Aucune entrée pour le moment') ]),
]
} },
{ id: 'settings', title: [ text('Paramètres') ], content: () => [
] },
{ id: 'ressources', title: [ text('Ressources') ], content: () => [
] }
], { focused: 'campaign', class: { container: 'max-w-[900px] w-[900px] h-full', content: 'overflow-auto', tabbar: 'gap-4' } }),
])
]))
}
}

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 } 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,15 +200,13 @@ export class Node extends EventTarget
{
properties: CanvasNode;
nodeDom!: HTMLDivElement;
nodeDom?: HTMLElement;
constructor(properties: CanvasNode)
{
super();
this.properties = properties;
this.getDOM()
}
protected getDOM()
@@ -216,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])
])
]);
@@ -230,12 +228,19 @@ export class Node extends EventTarget
}
}
get dom()
{
if(this.nodeDom === undefined)
this.getDOM();
return this.nodeDom;
}
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
@@ -253,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])
])
]);
@@ -270,7 +275,7 @@ export class NodeEditable extends Node
if(!this.dirty)
return;
Object.assign(this.nodeDom.style, {
Object.assign(this.nodeDom!.style, {
transform: `translate(${this.properties.x}px, ${this.properties.y}px)`,
width: `${this.properties.width}px`,
height: `${this.properties.height}px`,
@@ -282,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()
@@ -329,7 +334,7 @@ export class Edge extends EventTarget
{
properties: CanvasEdge;
edgeDom!: HTMLDivElement;
edgeDom?: HTMLElement;
protected from: Node;
protected to: Node;
protected path: Path;
@@ -344,8 +349,6 @@ export class Edge extends EventTarget
this.to = to;
this.path = getPath(this.from.properties, properties.fromSide, this.to.properties, properties.toSide)!;
this.labelPos = labelCenter(this.from.properties, properties.fromSide, this.to.properties, properties.toSide);
this.getDOM();
}
protected getDOM()
@@ -364,6 +367,13 @@ export class Edge extends EventTarget
]);
}
get dom()
{
if(this.edgeDom === undefined)
this.getDOM();
return this.edgeDom;
}
get style()
{
return this.properties.color ? this.properties.color?.class ?
@@ -377,8 +387,8 @@ export class EdgeEditable extends Edge
private focusing: boolean = false;
private editing: boolean = false;
private pathDom!: SVGPathElement;
private inputDom!: HTMLDivElement;
private pathDom?: SVGPathElement;
private inputDom?: HTMLElement;
constructor(properties: CanvasEdge, from: NodeEditable, to: NodeEditable)
{
super(properties, from, to);
@@ -406,7 +416,7 @@ export class EdgeEditable extends Edge
update()
{
this.path = getPath(this.from.properties, this.properties.fromSide, this.to.properties, this.properties.toSide)!;
this.pathDom.setAttribute('d', this.path.path);
this.pathDom!.setAttribute('d', this.path.path);
}
}
@@ -431,8 +441,8 @@ export class Canvas
protected tweener: Tweener = new Tweener();
private debouncedTimeout: Timer = setTimeout(() => {}, 0);
protected transform!: HTMLDivElement;
container!: HTMLDivElement;
protected transform!: HTMLElement;
container!: HTMLElement;
protected firstX = 0;
protected firstY = 0;
@@ -469,16 +479,16 @@ export class Canvas
//const { loggedIn, user } = useUserSession();
this.transform = dom('div', { class: 'origin-center h-full' }, [
dom('div', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none' }, [
dom('div', {}, this.nodes.map(e => e.nodeDom)), dom('div', {}, this.edges.map(e => e.edgeDom)),
dom('div', {}, this.nodes.map(e => e.dom)), dom('div', {}, this.edges.map(e => e.dom)),
])
]);
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,
]);
@@ -688,14 +698,14 @@ export class CanvasEditor extends Canvas
private focused: NodeEditable | EdgeEditable | undefined;
private selection: Set<NodeEditable> = new Set();
private dragging: boolean = false;
private dragger: HTMLElement = dom('div', { class: 'border border-accent-blue absolute shadow-accent-blue pointer-events-none', style: { 'box-shadow': '0 0 2px var(--tw-shadow-color)' } });
private dragging = false;
private dragger = dom('div', { class: 'border border-accent-blue absolute shadow-accent-blue pointer-events-none', style: { 'box-shadow': '0 0 2px var(--tw-shadow-color)' } });
private pattern: SVGElement = svg('svg', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none' }, [
private pattern = svg('svg', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none' }, [
svg('pattern', { attributes: { id: 'canvasPattern', patternUnits: 'userSpaceOnUse' } }, [ svg('circle', { class: 'fill-light-35 dark:fill-dark-35', attributes: { cx: '0.75', cy: '0.75', r: '0.75' } }) ]),
svg('rect', { attributes: { x: '0', y: '0', width: '100%', height: '100%', fill: 'url(#canvasPattern)' } })
]);
private nodeHelper: HTMLElement = dom('div', { class: 'cursor-move absolute z-40', listeners: { mousedown: e => this.moveSelection(e) }, style: { width: '0px', height: '0px' } }, [
private nodeHelper = dom('div', { class: 'cursor-move absolute z-40', listeners: { mousedown: e => this.moveSelection(e) }, style: { width: '0px', height: '0px' } }, [
dom('span', { class: 'cursor-n-resize absolute -top-3 -right-3 -left-3 h-6 group', listeners: { mousedown: e => this.resizeSelection(e, 0, 1, 0, -1) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 top-1 left-1/2 -translate-x-2', listeners: { mousedown: e => this.dragNewEdge(e, 'top') } }) ]),
dom('span', { class: 'cursor-s-resize absolute -bottom-3 -right-3 -left-3 h-6 group', listeners: { mousedown: e => this.resizeSelection(e, 0, 0, 0, 1) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 bottom-1 left-1/2 -translate-x-2', listeners: { mousedown: e => this.dragNewEdge(e, 'bottom') } }) ]),
dom('span', { class: 'cursor-e-resize absolute -top-3 -bottom-3 -right-3 w-6 group', listeners: { mousedown: e => this.resizeSelection(e, 0, 0, 1, 0) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 right-1 top-1/2 -translate-y-2', listeners: { mousedown: e => this.dragNewEdge(e, 'right') } }) ]),
@@ -705,8 +715,8 @@ export class CanvasEditor extends Canvas
dom('span', { class: 'cursor-se-resize absolute -bottom-4 -right-4 w-8 h-8', listeners: { mousedown: e => this.resizeSelection(e, 0, 0, 1, 1) } }),
dom('span', { class: 'cursor-sw-resize absolute -bottom-4 -left-4 w-8 h-8', listeners: { mousedown: e => this.resizeSelection(e, 1, 0, -1, 1) } }),
]);
private edgeHelper: HTMLElement = dom('div', { class: 'absolute', listeners: { } });
private boxHelper: HTMLElement = dom('div', { class: '-m-2 border border-accent-purple absolute z-10 p-2 box-content', listeners: { mouseenter: () => this.focusSelection() } });
private edgeHelper = dom('div', { class: 'absolute', listeners: { } });
private boxHelper = dom('div', { class: '-m-2 border border-accent-purple absolute z-10 p-2 box-content', listeners: { mouseenter: () => this.focusSelection() } });
protected override nodes: NodeEditable[] = [];
protected override edges: EdgeEditable[] = [];
@@ -763,24 +773,24 @@ export class CanvasEditor extends Canvas
this.transform = dom('div', { class: 'origin-center h-full' }, [
dom('div', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none' }, [
dom('div', {}, [...this.nodes.map(e => e.nodeDom), this.nodeHelper]), dom('div', {}, [...this.edges.map(e => e.edgeDom)]),
dom('div', {}, [...this.nodes.map(e => e.dom), this.nodeHelper]), dom('div', {}, [...this.edges.map(e => e.dom)]),
]), this.edgeHelper,
]);
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

2476
shared/character.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1077
shared/components.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,782 +0,0 @@
import type { RouteLocationAsRelativeTyped, RouteLocationRaw, RouteMapGeneric } from "vue-router";
import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node } from "./dom.util";
import { contextmenu, followermenu, minimizeBox, popper, teleport, tooltip, type FloatState } from "./floating.util";
import { clamp } from "./general.util";
import { Tree } from "./tree";
import type { Placement } from "@floating-ui/dom";
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 ? {
click: function(e)
{
e.preventDefault();
router.push(link);
}
} : undefined }, children);
}
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<HTMLElement>)
{
let state = { current: loading(size) };
fn.then((element) => {
state.current.replaceWith(element);
state.current = element;
}).catch(e => {
console.error(e);
state.current.remove();
})
return state;
}
export function button(content: Node | NodeChildren, onClick?: (this: HTMLElement) => void, cls?: 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
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50
*/
const btn = dom('button', { class: [`inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50
disabled:text-light-50 dark:disabled:text-dark-50 disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-dashed disabled:border-light-40 dark:disabled:border-dark-40`, cls], listeners: { click: () => disabled || (onClick && onClick.bind(btn)()) } }, Array.isArray(content) ? content : [content]);
let disabled = false;
Object.defineProperty(btn, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
btn.toggleAttribute('disabled', disabled);
}
})
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() {
if(currentValue !== e.value)
{
elements.forEach(e => e.toggleAttribute('data-selected', false));
this.toggleAttribute('data-selected', true);
if(!settings?.onChange || settings?.onChange(e.value) !== false)
{
currentValue = e.value;
}
}
}}}))
return div(['flex flex-row', settings?.class?.container], elements);
}
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: HTMLElement, target?: HTMLElement) {
close = followermenu(target ?? this, [ element ], { arrow: true, placement: settings?.position, offset: 8 }).close;
}
}
export type Option<T> = { text: string, render?: () => HTMLElement, value: T | Option<T>[] } | undefined;
type StoredOption<T> = { item: Option<T>, dom: HTMLElement, container?: HTMLElement, 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 }): HTMLElement
{
let context: { close: Function };
let focused: number | undefined;
options = options.filter(e => !!e);
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;
}
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;
}
}
window.addEventListener('keydown', handleKeys);
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') ]);
Object.defineProperty(select, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
select.toggleAttribute('data-disabled', disabled);
},
})
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 }): HTMLElement
{
let context: { close: Function };
let focused: number | undefined;
let selection: T[] = settings?.defaultValue ?? [];
options = options.filter(e => !!e);
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;
}
let disabled = settings?.disabled ?? false;
const textValue = text(selection.length > 0 ? ((options.find(f => f?.value === selection[0])?.text ?? '') + (selection.length > 1 ? ` +${selection.length - 1}` : '')) : '');
const optionElements = options.map((e, i) => {
if(e === undefined)
return;
const element = dom('div', { listeners: { click: (_e) => {
selection = selection.includes(e.value) ? selection.filter(f => f !== e.value) : [...selection, e.value];
textValue.textContent = selection.length > 0 ? ((options.find(f => f?.value === selection[0])?.text ?? '') + (selection.length > 1 ? ` +${selection.length - 1}` : '')) : '';
element.toggleAttribute('data-selected', selection.includes(e.value));
settings?.change && settings?.change(selection);
context && context.close && !_e.ctrlKey && context.close();
}, mouseenter: (e) => focus(i) }, class: ['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', settings?.class?.option], attributes: { 'data-selected': selection.includes(e.value) } }, [ text(e.text), icon('radix-icons:check', { class: 'hidden group-data-[selected]:block', noobserver: true }) ]);
return element;
});
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;
}
}
window.addEventListener('keydown', handleKeys);
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') ]);
Object.defineProperty(select, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
select.toggleAttribute('data-disabled', disabled);
},
})
return select;
}
export function combobox<T extends NonNullable<any>>(options: Option<T>[], settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' }): HTMLElement
{
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
let selected = true, tree: StoredOption<T>[] = [];
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();
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 });
if(!selected) container.classList.remove('!border-light-red', 'dark:!border-dark-red');
};
const hide = () => {
if(!selected) container.classList.add('!border-light-red', 'dark:!border-dark-red');
tree = [];
context && context.container.parentElement && context.close();
};
const progress = (option: StoredOption<T>) => {
if(!context || !context.container.parentElement || option.container === undefined)
return;
context.container.replaceChildren(option.container);
tree.push(option);
currentOptions = option.children!;
focus();
};
const back = () => {
tree.pop();
const last = tree.slice(-1)[0];
currentOptions = last?.children ?? optionElements;
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 => {
if(option === undefined)
return;
if(Array.isArray(option.value))
{
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 };
return stored;
}
else
{
return { item: option, dom: dom('div', { listeners: { click: (_e) => {
select.value = option.text;
settings?.change && settings?.change(option.value as T);
selected = true;
!_e.ctrlKey && hide();
}, mouseenter: () => focus(option.value) }, class: ['data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ option?.render ? option?.render() : text(option.text) ]) };
}
}
const filter = (value: string, option?: StoredOption<T>): StoredOption<T>[] => {
if(option && option.children !== undefined)
{
return option.children.flatMap(e => filter(value, e));
}
else if(option && option.item)
{
return option.item.text.toLowerCase().normalize().includes(value) ? [ option ] : [];
}
else
{
return [];
}
}
let disabled = settings?.disabled ?? false;
const optionElements = currentOptions = options.map(render).filter(e => !!e);
const select = dom('input', { listeners: { focus: show, input: () => {
focus();
currentOptions = context && select.value ? optionElements.flatMap(e => filter(select.value.toLowerCase().trim().normalize(), e)) : optionElements.filter(e => !!e);
context && context.container.replaceChildren(...currentOptions.map(e => e.dom));
selected = false;
if(!context || !context.container.parentElement) container.classList.add('!border-light-red', 'dark:!border-dark-red')
}, 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;
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' });
settings?.defaultValue && Tree.each(options, 'value', (item) => { if(item.value === settings?.defaultValue) select.value = item.text });
const container = dom('label', { class: ['inline-flex outline-none px-3 items-center justify-between text-sm font-semibold leading-none gap-1 bg-light-25 dark:bg-dark-25 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark:data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ select, icon('radix-icons:caret-down') ]);
Object.defineProperty(container, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
container.toggleAttribute('data-disabled', disabled);
select.toggleAttribute('disabled', disabled);
},
})
return container;
}
export function input(type: 'text' | 'number' | 'email' | 'password' | 'tel', settings?: { defaultValue?: string, change?: (value: string) => void, input?: (value: string) => void | 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: {
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,
blur: settings?.blur,
}})
if(settings?.defaultValue !== undefined) input.value = settings.defaultValue;
let value = input.value;
return input;
}
export function numberpicker(settings?: { defaultValue?: number, change?: (value: number) => void, input?: (value: number) => void, focus?: () => void, blur?: () => void, class?: Class, min?: number, max?: number, disabled?: boolean }): HTMLInputElement
{
let storedValue = settings?.defaultValue ?? 0;
const validateAndChange = (value: number) => {
if(isNaN(value))
field.value = '';
else
{
value = clamp(value, settings?.min ?? -Infinity, settings?.max ?? Infinity);
field.value = value.toString(10);
if(storedValue !== value)
{
storedValue = value;
return true;
}
}
return false;
}
const field = dom("input", { attributes: { disabled: settings?.disabled }, class: [`w-14 mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 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),
keydown: (e: KeyboardEvent) => {
if(field.disabled)
return;
switch(e.key)
{
case "ArrowUp":
validateAndChange(storedValue + (e.ctrlKey ? 10 : 1)) && settings?.input && settings.input(storedValue);
break;
case "ArrowDown":
validateAndChange(storedValue - (e.ctrlKey ? 10 : 1)) && settings?.input && settings.input(storedValue);
break;
case "PageUp":
settings?.max && validateAndChange(settings.max) && settings?.input && settings.input(storedValue);
break;
case "PageDown":
settings?.min && validateAndChange(settings.min) && settings?.input && settings.input(storedValue);
break;
default:
return;
}
},
change: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.change && settings.change(storedValue),
focus: () => settings?.focus && settings.focus(),
blur: () => settings?.blur && settings.blur(),
}});
if(settings?.defaultValue !== undefined) field.value = storedValue.toString(10);
return field;
}
// Open by default
export function foldable(content: NodeChildren | (() => NodeChildren), title: NodeChildren, settings?: { open?: boolean, class?: { container?: Class, title?: Class, content?: Class, icon?: Class } })
{
let _content: NodeChildren;
const display = (state: boolean) => {
if(state && !_content)
{
_content = typeof content === 'function' ? content() : content;
_content && contentContainer.replaceChildren(..._content.filter(e => !!e));
}
}
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', noobserver: true }) ]), div(['flex-1', settings?.class?.title], title) ]),
contentContainer
]);
display(settings?.open ?? true);
fold.toggleAttribute('data-active', settings?.open ?? true);
return fold;
}
type TableRow = Record<string, (() => HTMLElement) | HTMLElement | string>;
export function table(content: TableRow[], headers: TableRow, properties?: { class?: { table?: Class, header?: Class, body?: Class, row?: Class, cell?: Class } })
{
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 } })
{
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: {
click: function(e: Event) {
if(this.hasAttribute('data-disabled'))
return;
state = !state;
element.setAttribute('data-state', state ? "checked" : "unchecked");
settings?.change && settings.change(state);
}
}
}, [ 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: HTMLElement, value: boolean) => void, disabled?: 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: {
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);
}
}
}, [ 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] }), ]);
return element;
}
export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content: NodeChildren | (() => NodeChildren) }>, settings?: { focused?: string, class?: { container?: Class, tabbar?: Class, title?: Class, content?: Class }, switch?: (tab: string) => void | boolean }): HTMLDivElement & { refresh: () => void }
{
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;
if(settings?.switch && settings.switch(e.id) === false)
return;
titles.forEach(e => e.toggleAttribute('data-focus', false));
this.toggleAttribute('data-focus', true);
focus = e.id;
const _content = typeof e.content === 'function' ? e.content() : e.content;
_content && content.replaceChildren(..._content?.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 container = div(['flex flex-col', settings?.class?.container], [
div(['flex flex-row items-center gap-1', settings?.class?.tabbar], titles),
content
]);
Object.defineProperty(container, 'refresh', {
writable: false,
configurable: false,
enumerable: false,
value: () => {
let _content = tabs.find(e => e.id === focus)?.content;
_content = (typeof _content === 'function' ? _content() : _content);
_content && content.replaceChildren(..._content.filter(e => !!e));
}
})
return container as HTMLDivElement & { refresh: () => void };
}
export function floater(container: HTMLElement, content: NodeChildren | (() => NodeChildren), settings?: { href?: RouteLocationRaw, class?: Class, style?: Record<string, string | undefined | boolean | number> | string, position?: Placement, pinned?: boolean, 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: 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 ?? {});
if(settings?.pinned)
{
events.onshow = (state) => {
if(!settings?.events?.onshow || settings?.events?.onshow(state))
{
floating.show();
pin();
}
return false;
};
}
const dragstart = (e: MouseEvent) => {
e.preventDefault();
if(minimized)
return;
window.addEventListener('mousemove', dragmove);
window.addEventListener('mouseup', dragend);
const box = floating.content.getBoundingClientRect();
diffX = e.clientX - box.x;
diffY = e.clientY - box.y;
};
const resizestart = (e: MouseEvent) => {
e.preventDefault();
window.addEventListener('mousemove', resizemove);
window.addEventListener('mouseup', resizeend);
};
const dragmove = (e: MouseEvent) => {
const box = floating.content.getBoundingClientRect();
const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight };
box.x = clamp(e.clientX - diffX!, viewbox?.left ?? 0, viewbox.right - box.width);
box.y = clamp(e.clientY - diffY!, viewbox?.top ?? 0, viewbox.bottom - box.height);
Object.assign(floating.content.style, {
left: `${box.x}px`,
top: `${box.y}px`,
});
};
const resizemove = (e: MouseEvent) => {
const box = floating.content.getBoundingClientRect();
const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight };
box.width = clamp(e.clientX - box.x, 200, Math.min(750, viewbox.right - box.x));
box.height = clamp(e.clientY - box.y, 150, Math.min(750, viewbox.bottom - box.y));
Object.assign(floating.content.style, {
width: `${box.width}px`,
height: `${box.height}px`,
});
};
const dragend = (e: MouseEvent) => {
e.preventDefault();
window.removeEventListener('mousemove', dragmove);
window.removeEventListener('mouseup', dragend);
};
const resizeend = (e: MouseEvent) => {
e.preventDefault();
window.removeEventListener('mousemove', resizemove);
window.removeEventListener('mouseup', resizeend);
};
const pin = () => {
if(floating.content.hasAttribute('data-pinned'))
return;
const box = floating.content.children.item(0)!.getBoundingClientRect();
const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight };
Object.assign(floating.content.style, {
left: `${clamp(box.left, viewbox.left, viewbox.right)}px`,
top: `${clamp(box.top, viewbox.top, viewbox.bottom)}px`,
width: `${box.width + 21}px`,
height: `${box.height + 21}px`,
});
floating.content.attributeStyleMap.delete('bottom');
floating.content.attributeStyleMap.delete('right');
floating.stop();
floating.content.addEventListener('mousedown', function() {
if(!floating.content.hasAttribute('data-pinned'))
return;
[...this.parentElement?.children ?? []].forEach(e => (e as any as HTMLElement).attributeStyleMap.set('z-index', -1));
this.attributeStyleMap.set('z-index', 0);
}, { passive: true });
}
const minimize = () => {
minimized = !minimized;
floating.content.toggleAttribute('data-minimized', minimized);
if(minimized)
{
minimizeRect = floating.content.getBoundingClientRect();
Object.assign(floating.content.style, {
width: `150px`,
height: `21px`,
position: 'initial',
});
floating.content.style.setProperty('top', null);
floating.content.style.setProperty('left', null);
floating.content.style.setProperty('bottom', null);
floating.content.style.setProperty('right', null);
minimizeBox.appendChild(floating.content);
}
else
{
Object.assign(floating.content.style, {
left: `${minimizeRect.left}px`,
top: `${minimizeRect.top}px`,
width: `${minimizeRect.width}px`,
height: `${minimizeRect.height}px`,
});
floating.content.style.setProperty('position', null);
teleport.appendChild(floating.content);
}
};
const floating = popper(container, {
arrow: true,
delay: settings?.pinned ? 0 : 150,
offset: 12,
cover: settings?.cover,
placement: settings?.position,
style: settings?.style,
class: 'bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 group-data-[pinned]:bg-light-15 dark:group-data-[pinned]:bg-dark-15 group-data-[pinned]:border-light-50 dark:group-data-[pinned]:border-dark-50 text-light-100 dark:text-dark-100 z-[45] relative group-data-[pinned]:h-full',
content: () => [
settings?.pinned !== undefined ? div('hidden group-data-[pinned]:flex flex-row items-center border-b border-light-35 dark:border-dark-35', [
dom('span', { class: 'flex-1 w-full h-5 cursor-move group-data-[minimized]:cursor-default text-xs px-2', listeners: { mousedown: dragstart }, text: (settings?.title?.substring(0, 1)?.toUpperCase() ?? '') + (settings?.title?.substring(1)?.toLowerCase() ?? '') }),
settings?.title ? tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { click: minimize } }, [icon('radix-icons:minus', { width: 12, height: 12, class: 'p-1' })]), text('Réduire'), 'top') : undefined,
settings?.href ? tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { mousedown: (e) => { ((e.ctrlKey || e.button === 1) ? window.open : useRouter().push)(useRouter().resolve(settings.href!).href); floating.hide(); } } }, [icon('radix-icons:external-link', { width: 12, height: 12, class: 'p-1' })]), 'Ouvrir', 'top') : undefined,
tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { mousedown: (e) => {
e.stopImmediatePropagation();
floating.hide();
floating.content.toggleAttribute('data-minimized', false);
minimized && Object.assign(floating.content.style, {
left: `${minimizeRect.left}px`,
top: `${minimizeRect.top}px`,
width: `${minimizeRect.width}px`,
height: `${minimizeRect.height}px`,
});
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 min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] 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
});
if(settings?.pinned === false)
floating.content.addEventListener('dblclick', pin);
return container;
}
export interface ToastConfig
{
closeable?: boolean
duration: number
title?: string
content?: string
timer?: boolean
type?: ToastType
}
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: HTMLDivElement;
static init()
{
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' });
document.body.appendChild(Toaster._container);
}
static add(_config: ToastConfig)
{
let start: number;
const dragstart = (e: MouseEvent) => {
window.addEventListener('mousemove', dragmove);
window.addEventListener('mouseup', dragend);
start = e.clientX;
};
const dragmove = (e: MouseEvent) => {
const drag = e.clientX - start;
if(drag > Toaster._MAX_DRAG)
{
dragend();
config.dom.animate([{ transform: `translateX(${drag}px)` }, { transform: `translateX(150%)` }], { duration: 100, easing: 'ease-out' });
Toaster.close(config);
}
else if(drag > 0)
{
config.dom.style.transform = `translateX(${drag}px)`;
}
};
const dragend = () => {
window.removeEventListener('mousemove', dragmove);
window.removeEventListener('mouseup', dragend);
config.dom.style.transform = `translateX(0px)`;
};
const config = _config as ToastDom;
const loader = config.timer ? div('bg-light-50 dark:bg-dark-50 h-full w-full transition-[width] ease-linear') : undefined;
loader?.animate([{ width: '0' }, { width: '100%' }], { duration: config.duration, easing: 'linear' });
config.dom = dom('div', { class: 'ToastRoot bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 group select-none', attributes: { 'data-type': config.type, 'data-state': 'open' } }, [
div('grid grid-cols-8 px-3 pt-2 pb-2', [
config.title ? dom('h4', { class: 'font-semibold text-xl col-span-7 text-light-100 dark:text-dark-100', text: config.title }) : undefined,
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, noobserver: true, 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.dom.addEventListener('mousedown', dragstart);
config.dom.animate([{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }], { duration: 150, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
Toaster._container?.appendChild(config.dom);
Toaster._list.push(config);
setTimeout(() => Toaster.close(config), config.duration);
}
static clear(type?: ToastType)
{
Toaster._list.filter(e => e.type !== type || (Toaster.close(e), false));
}
private static close(config: ToastDom)
{
config.dom.animate([
{ opacity: 1 }, { opacity: 0 },
], { easing: 'ease-in', duration: 100 }).onfinish = () => config.dom.remove();
Toaster._list = Toaster._list.filter(e => e !== config);
}
}

View File

@@ -1,14 +1,14 @@
import { safeDestr as parse } from 'destr';
import { Canvas, CanvasEditor } from "#shared/canvas.util";
import render from "#shared/markdown.util";
import { confirm, contextmenu, tooltip } from "#shared/floating.util";
import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util";
import { async, 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, 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';
@@ -16,6 +16,9 @@ import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import type { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';
import type { CharacterConfig, FeatureID, MainStat, TrainingLevel } from '~/types/character';
import { getText } from './i18n';
import { mainStatTexts } from './character';
export type FileType = keyof ContentMap;
export interface ContentMap
@@ -138,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);
}
@@ -175,12 +178,36 @@ export class Content
}
static async getContent(id: string): Promise<LocalContent | undefined>
{
await Content.ready;
const overview = Content._overview[id];
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'>>)
{
@@ -247,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);
@@ -280,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 => {
@@ -578,12 +592,12 @@ export const iconByType: Record<FileType, string> = {
export class Editor
{
tree!: TreeDOM;
container: HTMLDivElement;
container: HTMLElement;
selected?: Recursive<LocalContent & { element?: HTMLElement }>;
private instruction: HTMLDivElement;
private cleanup: CleanupFn;
private instruction: HTMLElement;
private cleanup?: CleanupFn;
private history: History;
@@ -647,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();
@@ -699,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'),
@@ -728,20 +743,20 @@ 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?: 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', 'add', [{ element: item, from: undefined, to: nextTo.order + 1 }]);
this.history.add('overview', [{ element: item, from: undefined, to: nextTo.order + 1, event: 'add' }]);
}
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?: HTMLElement })
{
@@ -756,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,13 +784,13 @@ export class Editor
{
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?: 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
{
@@ -880,13 +895,13 @@ 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
{
@@ -917,7 +932,15 @@ export class Editor
}
unmount()
{
this.cleanup();
this.cleanup && this.cleanup();
}
undo()
{
this.history.undo();
}
redo()
{
this.history.redo();
}
}
@@ -931,4 +954,51 @@ export function getPath(item: any): string
return parsePath(item.title);
else
return parsePath(item.title) ?? item.path;
}
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" };
const SPELL_ELEMENTS_TEXTS = { 'fire': 'feu', 'ice': 'glace', 'thunder': 'foudre', 'earth': 'terre', 'arcana': 'arcane', 'air': 'air', 'nature': 'nature', 'light': 'lumiere', 'psyche': 'psy' };
const SPELL_SPEED_TEXTS = { 'action': 'action', 'reaction': 'réaction', 'number': '%1 minute(s)' };
return Object.values(config.spells).sort((a, b) => a.rank - b.rank || SPELL_ELEMENTS.indexOf(a.type) - SPELL_ELEMENTS.indexOf(b.type)).map(e => `- ${e.name} ${e.elements.map(el => '#' + SPELL_ELEMENTS_TEXTS[el]).join(' + ')} ${SPELL_TYPE_TEXTS[e.type]} (${e.cost} mana, ${typeof e.speed === 'number' ? SPELL_SPEED_TEXTS['number'].replace('%1', e.speed.toString()).replace('(s)', e.speed === 1 ? '' : 's') : SPELL_SPEED_TEXTS[e.speed]}${e.concentration ? ', [[1. Magie#La concentration|concentration]]' : ''}, ${e.range === 'personnal' ? 'Personnel' : e.range === 0 ? 'Toucher' : e.range + ' cases'})\nTags: ${e.tags?.join(', ') ?? '-'}\n\t${e.description.endsWith('.') ? e.description : e.description + '.'}`).join('\n\n');
}
export function buildTrainingTree()
{
const getLocalID = () => { for (var t = [], n = 0; n < 16; n++) t.push((16 * Math.random() | 0).toString(16)); return t.join(""); }, colors = ["4", "1", "6", "6"], WIDTH = 448, HEIGHT = (lines: number) => lines === 0 ? 64 : (40 + (24 * lines)), PADDING_X = 48, PADDING_Y = 80;
const _tree = {nodes: [], edges: [] } as any;
let _maxX = 0,_minY = 0,_maxY = 0;
const tree = (branch: Record<TrainingLevel, FeatureID[]>, name: string, color: string) => {
let previousLevel = {} as any, maxAmount = Object.values(branch).reduce((p, v) => Math.max(p, v.length), 0), minX = _maxX + PADDING_X, maxX = minX + (PADDING_X + WIDTH) * maxAmount + PADDING_X, minY = _minY, maxY = _minY;
Object.entries(branch).forEach(([i, e]: [string, FeatureID[]]) => {
const nodes = e.map((_e, _i) => ({ type: 'text', id: getLocalID(), text: getText(config.features[_e]?.description), color: colors[_i] ?? undefined, x: lerp(minX, maxX - PADDING_X - PADDING_X - WIDTH, (_i + ((maxAmount - e.length) / 2)) / (maxAmount - 1)), y: maxY, width: WIDTH, height: HEIGHT(Math.ceil(renderMDAsText(getText(config.features[_e]?.description)).length / 52)) }));
maxY += nodes.map(e => e.height).reduce((p, v) => Math.max(p, v), 0) + PADDING_Y;
_tree.nodes.push(...nodes);
nodes.forEach((_e, _i) => { if(previousLevel[_i]) _tree.edges.push({id: getLocalID(), fromNode: previousLevel[_i].id, fromSide: "bottom", toNode: _e.id, toSide: "top", color: colors[_i] ?? undefined }); else if(previousLevel[0]) _tree.edges.push({id: getLocalID(), fromNode: previousLevel[0].id, fromSide: "bottom", toNode: _e.id, toSide: "top", color: colors[_i] ?? undefined }); });
previousLevel = nodes;
});
_tree.nodes.push({ type: 'group', label: name, x: minX - PADDING_X, y: minY - PADDING_Y, width: maxX - minX, height: maxY - minY + PADDING_Y, color: color })
_maxX = Math.max(_maxX, maxX);
_maxY = Math.max(_maxY, maxY + PADDING_Y);
};
tree(config.training.strength, 'Force', '1');
tree(config.training.dexterity, 'Dextérité', '4');
tree(config.training.constitution, 'Constitution', '2');
tree(config.training.intelligence, 'Intelligence', '5');
tree(config.training.curiosity, 'Curiosité', '3');
tree(config.training.charisma, 'Charisme', '#fe39ee');
tree(config.training.psyche, 'Psyché', '6');
return _tree;
}
export function buildTrainingFile()
{
return Object.entries(config.training).map(e => {
return `# ${mainStatTexts[e[0] as MainStat]}\n` + Object.entries(e[1]).map(_e => `## Niveau ${_e[0]}\n` + _e[1].map(feature => renderMDAsText(getText(config.features[feature]!.description))).join('\nou\n')).join('\n');
}).join('\n');
}

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

276
shared/dom.ts Normal file
View File

@@ -0,0 +1,276 @@
import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon';
import { loading } from './components';
import { _defer, raw, reactivity, type Reactive } from './reactive';
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: HTMLElement, ev: HTMLElementEventMap[K]) => any) | {
options?: boolean | AddEventListenerOptions;
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any;
} | undefined;
export interface DOMList<T> extends Array<T>{
render(redraw?: boolean): void;
};
export interface NodeProperties
{
attributes?: Record<string, Reactive<string | undefined | boolean | number>>;
text?: Reactive<string | number | Text>;
class?: Reactive<Class>;
style?: Reactive<Record<string, string | undefined | boolean | number> | string>;
listeners?: {
[K in keyof HTMLElementEventMap]?: Listener<K>
};
}
function append(dom: Element, children: Node | Node[] | undefined)
{
if(Array.isArray(children))
{
children.forEach(e => e && dom.appendChild(e));
}
else
{
children && dom.appendChild(children);
}
}
export const cancelPropagation = (e: Event) => e.stopImmediatePropagation();
export function dom<T extends keyof HTMLElementTagNameMap>(tag: T, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[T];
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: { render: (data: U, cache?: Node | Node[]) => Node | Node[], list?: Reactive<Array<U>>, fallback?: () => Node | Node[] }): HTMLElementTagNameMap[T] & { array?: DOMList<U> };
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U, cache?: Node | Node[]) => Node | Node[], list?: Reactive<Array<U>>, fallback?: () => Node | Node[] }): HTMLElementTagNameMap[T] & { array?: DOMList<U> }
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U, cache?: Node | Node[]) => Node | Node[], list?: Reactive<Array<U>>, fallback?: () => Node | Node[] }): HTMLElementTagNameMap[T] & { array?: DOMList<U> }
{
const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList<U> };
const _cache = new Map<U, Node | Node[] | undefined>();
const seen = new Set<U>(); // Cache pruning utility
if(children)
{
if(Array.isArray(children))
{
reactivity(children, (_children) => {
element.replaceChildren();
for(const c of _children)
{
const child = typeof c === 'function' ? c() : c;
child && element.appendChild(child);
}
});
}
else if(children.list !== undefined)
{
let fallback: Node | Node[] | undefined;
children.fallback && reactivity(children.fallback, (_fallback) => {
if(_fallback)
{
if(Array.isArray(fallback) && fallback.filter(e => !!e)[0] && fallback.filter(e => !!e)[0]!.parentElement === element)
element.replaceChildren(), append(element, _fallback);
else if(!Array.isArray(fallback) && fallback?.parentElement === element)
element.replaceChildren(), append(element, _fallback);
}
fallback = _fallback;
});
reactivity(children.list, (list) => {
element.replaceChildren();
if(list.length === 0)
{
fallback ??= children?.fallback ? children.fallback() : undefined;
append(element, fallback);
}
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();
}
})
}
}
if(properties?.attributes)
{
for(const [k, v] of Object.entries(properties.attributes))
{
reactivity(properties.attributes[k], (attribute) => {
if(typeof attribute === 'string' || typeof attribute === 'number') element.setAttribute(k, attribute.toString(10));
else if(typeof attribute === 'boolean') element.toggleAttribute(k, attribute);
});
}
}
if(properties?.text)
{
reactivity(properties.text, (text) => {
if(typeof text === 'string')
element.textContent = text;
else if(typeof text === 'number')
element.textContent = text.toString();
else
element.appendChild(text as Text);
})
}
if(properties?.listeners)
{
for(let [k, v] of Object.entries(properties.listeners))
{
const key = k as keyof HTMLElementEventMap, value = v as Listener<typeof key>;
if(typeof value === 'function')
element.addEventListener(key, value.bind(element));
else if(value)
element.addEventListener(key, value.listener.bind(element), value.options);
}
}
if(properties?.class)
{
reactivity(properties?.class, (classes) => element.setAttribute('class', mergeClasses(classes)));
}
if(properties?.style)
{
reactivity(properties.style, (style) => {
if(typeof style === 'string') element.setAttribute('style', style);
else for(const [k, v] of Object.entries(style)) if(v !== undefined && v !== false) element.attributeStyleMap.set(k, v);
})
}
return element;
}
export function div(cls?: Reactive<Class>, children?: NodeChildren): HTMLElementTagNameMap['div']
export function div<U extends any>(cls?: Reactive<Class>, children?: { render: (data: U, cache?: Node | Node[]) => Node | Node[], list?: Reactive<Array<U>>, fallback?: () => Node | Node[] }): HTMLElementTagNameMap['div'] & { array: DOMList<U> }
export function div<U extends any>(cls?: Reactive<Class>, children?: NodeChildren | { render: (data: U, cache?: Node | Node[]) => Node | Node[], list?: Reactive<Array<U>>, fallback?: () => Node | Node[] }): HTMLElementTagNameMap['div'] & { array?: DOMList<U> }
{
return dom("div", { class: cls }, children);
}
export function span(cls?: Reactive<Class>, text?: Reactive<string | number | Text>): HTMLElementTagNameMap['span']
{
return dom("span", { class: cls, text: text });
}
export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: Array<Reactive<SVGElement>>): SVGElementTagNameMap[K]
{
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
if(children)
{
for(const c of children)
{
const child = typeof c === 'function' ? c() : c;
child && element.appendChild(child);
}
}
if(properties?.attributes)
{
for(const [k, v] of Object.entries(properties.attributes))
{
reactivity(properties.attributes[k], (attribute) => {
if(typeof attribute === 'string' || typeof attribute === 'number') element.setAttribute(k, attribute.toString(10));
else if(typeof attribute === 'boolean') element.toggleAttribute(k, attribute);
});
}
}
if(properties?.text)
{
reactivity(properties.text, (text) => {
if(typeof text === 'string')
element.textContent = text;
else if(typeof text === 'number')
element.textContent = text.toString();
else
element.appendChild(text as Text);
})
}
if(properties?.class)
{
reactivity(properties?.class, (classes) => element.setAttribute('class', mergeClasses(classes)));
}
if(properties?.style)
{
reactivity(properties.style, (style) => {
if(typeof style === 'string') element.setAttribute('style', style);
else for(const [k, v] of Object.entries(style)) if(v !== undefined && v !== false) element.attributeStyleMap.set(k, v);
})
}
return element;
}
export function text(data: Reactive<string | number>): Text
{
const text = document.createTextNode('');
reactivity(data, (txt) => text.textContent = txt.toString());
return text;
}
export interface IconProperties
{
mode?: 'svg' | 'style' | 'bg' | 'mask';
inline?: boolean;
width?: string | number;
height?: string | number;
hFlip?: boolean;
vFlip?: boolean;
rotate?: number;
style?: Reactive<Record<string, string | undefined> | string>;
class?: Class;
}
const iconLoadingRegistry: Map<string, Promise<Required<IconifyIcon>> | null | undefined> = new Map();
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 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))
{
timeout = setTimeout(() => { element.replaceChildren(loading('small')); }, 100);
if(!iconLoadingRegistry.has(name)) iconLoadingRegistry.set(name, loadIcon(name));
iconLoadingRegistry.get(name)?.then((icon) => target === name && build(icon));
}
else build(getIcon(name));
})
return element;
}
export function mergeClasses(classes: Class): string
{
if(typeof classes === 'string')
{
return classes.trim();
}
else if(Array.isArray(classes))
{
return classes.map(e => mergeClasses(e)).join(' ');
}
else if(classes)
{
return Object.entries(classes).filter(e => e[1]).map(e => e[0].trim()).join(' ');
}
else
{
return '';
}
}

Some files were not shown because too many files have changed in this diff Show More