90 Commits

Author SHA1 Message Date
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
Clément Pons
6f5566326e Add dynamic text compiling and dynamic children list rendering on DOM. 2025-12-08 17:41:39 +01:00
Clément Pons
b1229f81f6 Campaign character insertion and deletion. Updating the inventory rendering. Update of the character_config IDs. 2025-11-24 17:28:31 +01:00
Clément Pons
41ae5da98c Fix mails and validate succesful deletion of backend vue instance. 2025-11-24 10:13:28 +01:00
Clément Pons
00e7d647d3 Character Printer improvement, Campaign main block overflow 2025-11-19 17:40:19 +01:00
Clément Pons
c9f60d92ca Fix Campaign log DB and rendering. Migrate mail rendering to virtual DOM API. 2025-11-19 17:14:45 +01:00
Clément Pons
7a40f8abac WebSocket API, new ID/encrypt/decrypt algorithm. 2025-11-18 17:54:11 +01:00
2a158be3fa Beginning implementation of Figma campaign UI 2025-11-17 23:05:56 +01:00
Clément Pons
3de2b0fe19 Add character selection using campaign visibility and player characters in campaign 2025-11-17 17:54:28 +01:00
d8480e7366 Campaign sheet start 2025-11-16 23:43:54 +01:00
Clément Pons
dfbb31595e Migration to Nuxt v4 file structure and dependencies update 2025-11-13 10:05:41 +01:00
Clément Pons
dd4191bea6 Beginning campaign UI and WS to get player state. 2025-11-12 17:53:48 +01:00
Clément Pons
3ed9ab3dce Campaign REST API 2025-11-03 18:00:47 +01:00
Clément Pons
93eaa1e3e4 Merge branch 'dev' of https://git.peaceultime.com/Peaceultime/obsidian-visualiser into dev 2025-11-03 11:21:08 +01:00
Clément Pons
62c1ccf0b4 Add link autocompletion (limited) 2025-11-03 11:20:56 +01:00
d208049989 Add Health and Mana value changer 2025-11-02 23:16:48 +01:00
Clément Pons
6db6a4b19d New DB schema for campaigns 2025-10-30 14:05:12 +01:00
Clément Pons
fde752b6ed Compress stored content for improved caching size and speed. Add loading component on every Content ready awaiting to reduce first render time. 2025-10-28 17:57:20 +01:00
Clément Pons
1c3211d28e Content auto pulling, git pull link fix and cleaning console.log/console.time. 2025-10-28 16:45:35 +01:00
Clément Pons
ab36eec4de New CM6 live edition components and floating cache and persistance. 2025-10-28 16:23:45 +01:00
Clément Pons
b9970ccdf8 Add inventory management in character sheet. 2025-10-22 17:57:19 +02:00
Clément Pons
73b0fdf3f5 Typo fixes, add spell range to sheet and remove useContent 2025-10-21 17:49:21 +02:00
Clément Pons
25bd165f1d Merge branch 'dev' into HEAD 2025-10-21 17:26:16 +02:00
Clément Pons
5c1f41b0b7 Fix ProseH remains, rollback layout rendering and add proper scrolling to the character sheet tabs 2025-10-21 17:22:46 +02:00
feb2fb56c6 New default layout without vuejs rendering (still needs some fixes) 2025-10-19 23:35:11 +02:00
Clément Pons
df9ae95890 Note tab in character sheet 2025-10-15 17:01:23 +02:00
Clément Pons
72843f2425 Fix registration email and add no character friendly messages 2025-10-15 14:58:59 +02:00
Clément Pons
443612cc58 Floater pinned true handler, SQL schema update to handle private/public notes on character, fix Canvas zoom debounce on move. 2025-10-15 14:34:12 +02:00
Clément Pons
a577e3ccfc Checkbox and item panel improvements 2025-10-14 17:57:34 +02:00
Clément Pons
48e767944a Progress on ItemEditor interface and rendering 2025-10-13 17:56:22 +02:00
d187957915 Start implementing ItemEditor 2025-10-13 13:19:50 +02:00
Clément Pons
16cc3ee438 Floater imrprovement with parametrable show and hide events, title and minimization. 2025-10-10 16:57:36 +02:00
Clément Pons
26aa0847d9 Fix comrpessing bug on null buffers, make pinned floaters resizable and optimize a few things here and there 2025-10-06 17:42:16 +02:00
b19d2d1b41 Updated legal stuff, added floating popup that can be pin and move. Fix character compiler modifier updates not dirtying all dependents. 2025-10-05 23:54:37 +02:00
Clément Pons
89c4476ffb Merge branch 'dev' of https://git.peaceultime.com/Peaceultime/obsidian-visualiser into dev 2025-10-01 17:59:30 +02:00
Clément Pons
3113d8b0f3 Feature choice UI rework, feature editor fixes, new character manage page UI with tabgroup and action config 2025-10-01 17:59:14 +02:00
2b39f26722 Merge branch 'dev' of https://git.peaceultime.com/peaceultime/obsidian-visualiser into dev 2025-09-30 21:50:59 +02:00
d2a807694b Fix compression error 2025-09-30 21:36:40 +02:00
Clément Pons
eb0c33deae New ability display, sereval Character compile and creation fixes 2025-09-30 18:03:38 +02:00
Clément Pons
61d2d144b7 Spell UI, variables saving and mail server fixes (finally working in prod !!!) 2025-09-30 17:15:49 +02:00
Clément Pons
1642cd513f Work in progress: CharacterSheet implementation and FeatureChoice rework 2025-09-29 17:53:41 +02:00
Clément Pons
b1ac379f1a Work in progress: CharacterSheet implementation and FeatureChoice rework 2025-09-29 17:53:39 +02:00
81f191d5f6 Compress middleware 2025-09-14 20:46:48 +02:00
Clément Pons
423df7bc42 Add spell picker in the character sheet 2025-09-01 17:53:07 +02:00
c93cc4078c New Toaster class, Ability and Resistance removed from config file and choices improvement 2025-08-31 23:52:11 +02:00
Clément Pons
17bc232602 Changes tooltips reference, update character sheet UI, getID now embed the ID_SIZE, new ability max option in feature effect. 2025-08-29 17:46:08 +02:00
Clément Pons
042d4479ee Ajust database schema to recent changes 2025-08-26 17:34:34 +02:00
Clément Pons
da93fcd82d Homebrew manager completed ! 2025-08-26 15:27:47 +02:00
Clément Pons
80a94bee86 Remove unused components, change zod to v4 and cahnge a few character properties 2025-08-26 13:21:42 +02:00
Clément Pons
5387dc66c3 Character BuilderTabs rework 2025-08-26 10:18:07 +02:00
Clément Pons
6fe3746df4 Alignment handled as string instead of objects 2025-08-26 10:17:46 +02:00
893247e1eb Aspect and Spell editor, multiselect component. 2025-08-26 00:17:08 +02:00
Clément Pons
69ee62c08e Convert list texts to a separate i18n text, allowing translation and fixing action/passive/... removal. Character sheet now use the character compiler. 2025-08-25 17:35:15 +02:00
247b14b2c8 Various fixes to select, combobox and feature editor. 2025-08-24 23:35:57 +02:00
Clément Pons
658499749d Nearly finished FeatureEditor for choices 2025-08-20 22:25:47 +02:00
Clément Pons
06276b3fbc Progress on option rendering 2025-08-18 17:42:07 +02:00
Clément Pons
72982a4ea9 Impoved FloatingUI components and create a PickableFeature class 2025-08-13 17:39:58 +02:00
Clément Pons
4e5ea504ea Add combobox groups 2025-08-11 17:52:53 +02:00
920ce2e1b6 Feature Builder panel progress 2025-08-11 09:39:41 +02:00
Clément Pons
86556ec604 Completed first people effects 2025-07-23 18:09:13 +02:00
Clément Pons
7d6f9162ed Finalize CharacterBuilder 2025-07-22 17:46:16 +02:00
3ef98df5d2 Add LevelPicker 2025-07-22 00:05:06 +02:00
Clément Pons
ba8c7b05e6 Merge branch 'character' into dev 2025-07-21 18:00:00 +02:00
Clément Pons
9ca546f490 Progressing on canvas editor class 2025-04-22 09:06:45 +02:00
Clément Pons
2c80cb2456 Fix pull job links. Canvas now start centered and zoomed out based on nodes positions. 2025-04-02 17:25:29 +02:00
Clément Pons
6100fd9411 Fix content read pages and proses getting content. Start working on CanvasEditor. 2025-04-01 17:23:26 +02:00
1d41514b26 Update DB schema to include an ID and split overview and content. Progressing on ContentEditor with the ID fixing many issues. Adding modal and sync features. 2025-03-31 01:19:58 +02:00
227d7224e5 Fix package.json and bun.lock 2025-03-30 14:01:05 +02:00
f49fdaac79 Merge branch 'dev' of https://git.peaceultime.com/peaceultime/obsidian-visualiser into dev 2025-03-28 19:42:01 +01:00
41c19b4bfb Big rework to include OPFS API for local edits. Big components rework in vanilla JS to optimize. Unfinished, DO NOT SHIP YET ! 2025-03-28 19:38:06 +01:00
Clément Pons
c0e625a8cb Update codemirror to v6 and refactor editor to make own HyperMD like rich content editor. 2025-03-20 18:03:13 +01:00
269 changed files with 37867 additions and 15861 deletions

46
app.vue
View File

@@ -1,46 +0,0 @@
<template>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
<NuxtRouteAnnouncer/>
<NuxtLoadingIndicator />
<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">
<NuxtPage />
</div>
</NuxtLayout>
<Toaster v-model="list" />
</TooltipProvider>
</div>
</template>
<script setup lang="ts">
provideToaster();
const { list } = useToast();
</script>
<style>
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-thumb {
@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;
}
::-webkit-scrollbar-corner {
@apply bg-transparent;
}
</style>

256
app/app.vue Normal file
View File

@@ -0,0 +1,256 @@
<template>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
<NuxtRouteAnnouncer/>
<NuxtLoadingIndicator :throttle="50"/>
<NuxtLayout>
<div class="xl:px-12 xl:py-8 lg:px-8 lg:py-6 px-6 py-3 flex flex-1 justify-center overflow-auto max-h-full max-w-full relative" id="mainContainer">
<NuxtPage />
</div>
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
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;
document.querySelectorAll(`a[href="${from.path}"][data-active]`).forEach(e => e.classList.remove(e.getAttribute('data-active') ?? ''));
document.querySelectorAll(`a[href="${to.path}"][data-active]`).forEach(e => e.classList.add(e.getAttribute('data-active') ?? ''));
});
onUnmounted(() => {
unmount();
})
});
</script>
<style>
iconify-icon
{
display: inline-block;
width: attr(width px, 1rem);
height: attr(height px, 1rem);
box-sizing: content-box;
}
.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;
}
.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;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-thumb {
@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;
}
.callout[data-type="abstract"],
.callout[data-type="summary"],
.callout[data-type="tldr"]
{
@apply bg-light-cyan;
@apply dark:bg-dark-cyan;
@apply text-light-cyan;
@apply dark:text-dark-cyan;
}
.callout[data-type="info"]
{
@apply bg-light-blue;
@apply dark:bg-dark-blue;
@apply text-light-blue;
@apply dark:text-dark-blue;
}
.callout[data-type="todo"]
{
@apply bg-light-blue;
@apply dark:bg-dark-blue;
@apply text-light-blue;
@apply dark:text-dark-blue;
}
.callout[data-type="important"]
{
@apply bg-light-cyan;
@apply dark:bg-dark-cyan;
@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 text-light-cyan;
@apply dark:text-dark-cyan;
}
.callout[data-type="success"],
.callout[data-type="check"],
.callout[data-type="done"]
{
@apply bg-light-green;
@apply dark:bg-dark-green;
@apply text-light-green;
@apply dark:text-dark-green;
}
.callout[data-type="question"],
.callout[data-type="help"],
.callout[data-type="faq"]
{
@apply bg-light-orange;
@apply dark:bg-dark-orange;
@apply text-light-orange;
@apply dark:text-dark-orange;
}
.callout[data-type="warning"],
.callout[data-type="caution"],
.callout[data-type="attention"]
{
@apply bg-light-orange;
@apply dark:bg-dark-orange;
@apply text-light-orange;
@apply dark:text-dark-orange;
}
.callout[data-type="failure"],
.callout[data-type="fail"],
.callout[data-type="missing"]
{
@apply bg-light-red;
@apply dark:bg-dark-red;
@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 text-light-red;
@apply dark:text-dark-red;
}
.callout[data-type="bug"]
{
@apply bg-light-red;
@apply dark:bg-dark-red;
@apply text-light-red;
@apply dark:text-dark-red;
}
.callout[data-type="example"]
{
@apply bg-light-purple;
@apply dark:bg-dark-purple;
@apply text-light-purple;
@apply dark:text-dark-purple;
}
.variant-cap
{
font-variant: small-caps;
}
.cm-editor
{
@apply bg-transparent;
@apply flex-1 h-full;
@apply font-sans;
@apply text-light-100 dark:text-dark-100;
}
.cm-focused
{
@apply outline-none;
}
.cm-editor .cm-content
{
@apply caret-light-100 dark:caret-dark-100;
}
.cm-line
{
@apply text-base;
@apply font-sans;
}
.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;
}
/* .cm-tooltip-autocomplete > ul {
@apply p-1;
} */
.cm-tooltip-autocomplete > ul > li {
@apply flex;
@apply flex-col;
@apply !py-1;
@apply hover:bg-light-30;
@apply dark:hover:bg-dark-30;
}
.cm-tooltip-autocomplete > ul > li[aria-selected] {
@apply !bg-light-35;
@apply dark:!bg-dark-35;
}
.cm-completionIcon {
@apply !hidden;
}
.cm-completionLabel {
@apply px-4;
@apply font-sans;
@apply font-normal;
@apply text-base;
@apply text-light-100;
@apply dark:text-dark-100;
}
.cm-completionMatchedText {
@apply font-bold;
@apply !no-underline;
}
.cm-completionDetail {
@apply font-sans;
@apply font-normal;
@apply text-sm;
@apply text-light-60;
@apply dark:text-dark-60;
@apply italic;
}
::-webkit-scrollbar-corner {
@apply bg-transparent;
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import render, { type MDProperties } from '~~/shared/markdown'
const { content, filter, properties } = defineProps<{
content?: string,
filter?: string,
properties?: MDProperties
}>();
const container = useTemplateRef('container');
content && onMounted(() => {
queueMicrotask(() => {
container.value && content && container.value.replaceChildren(render(content, filter, properties));
})
})
</script>
<template>
<div ref="container"></div>
</template>

View File

@@ -12,7 +12,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
const { src, icon, text, size = 'medium' } = defineProps<{ const { src, icon, text, size = 'medium' } = defineProps<{
src: string src: string
icon?: string icon?: string

View File

@@ -17,7 +17,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
const { label, disabled = false, defaultOpen = false } = defineProps<{ const { label, disabled = false, defaultOpen = false } = defineProps<{
label?: string label?: string
disabled?: boolean disabled?: boolean

View File

@@ -31,7 +31,7 @@
<script setup lang="ts" generic="T extends string | number | boolean | Record<string, any>"> <script setup lang="ts" generic="T extends string | number | boolean | Record<string, any>">
import { ComboboxInput, ComboboxTrigger, ComboboxViewport, ComboboxContent, ComboboxPortal, ComboboxRoot } from 'radix-vue' import { ComboboxInput, ComboboxTrigger, ComboboxViewport, ComboboxContent, ComboboxPortal, ComboboxRoot } from 'radix-vue'
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
const { disabled = false, position = 'popper', multiple = false } = defineProps<{ const { disabled = false, position = 'popper', multiple = false } = defineProps<{
placeholder?: string placeholder?: string
disabled?: boolean disabled?: boolean

View File

@@ -65,7 +65,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownOption } from './DropdownMenu.vue'; import type { DropdownOption } from './DropdownMenu.vue';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
const { options } = defineProps<{ const { options } = defineProps<{
options: DropdownOption[] options: DropdownOption[]

View File

@@ -10,7 +10,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
const { min = 0, max = 100, disabled = false, step = 1, label } = defineProps<{ const { min = 0, max = 100, disabled = false, step = 1, label } = defineProps<{
min?: number min?: number

View File

@@ -15,7 +15,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
export interface RadioOption { export interface RadioOption {
label: string label: string
value: string value: string

View File

@@ -30,7 +30,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SelectContent, SelectPortal, SelectRoot, SelectScrollDownButton, SelectScrollUpButton, SelectTrigger, SelectValue, SelectViewport } from 'radix-vue' import { SelectContent, SelectPortal, SelectRoot, SelectScrollDownButton, SelectScrollUpButton, SelectTrigger, SelectValue, SelectViewport } from 'radix-vue'
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
const { disabled = false, position = 'popper' } = defineProps<{ const { disabled = false, position = 'popper' } = defineProps<{
placeholder?: string placeholder?: string
disabled?: boolean disabled?: boolean

View File

@@ -8,7 +8,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
import { SelectItem, SelectItemIndicator, SelectItemText } from 'radix-vue' import { SelectItem, SelectItemIndicator, SelectItemText } from 'radix-vue'
const { disabled = false, value } = defineProps<{ const { disabled = false, value } = defineProps<{
disabled?: boolean disabled?: boolean

View File

@@ -15,7 +15,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
const { label, disabled, onIcon, offIcon } = defineProps<{ const { label, disabled, onIcon, offIcon } = defineProps<{
label?: string label?: string
disabled?: boolean disabled?: boolean

View File

@@ -12,7 +12,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
const { placeholder } = defineProps<{ const { placeholder } = defineProps<{
placeholder?: string placeholder?: string

View File

@@ -2,7 +2,9 @@ import { Database } from "bun:sqlite";
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite"; import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from '../db/schema'; import * as schema from '../db/schema';
let instance: BunSQLiteDatabase<typeof schema>; let instance: BunSQLiteDatabase<typeof schema> & {
$client: Database;
};
export default function useDatabase() export default function useDatabase()
{ {
if(!instance) if(!instance)
@@ -13,6 +15,7 @@ export default function useDatabase()
instance.run("PRAGMA journal_mode = WAL;"); instance.run("PRAGMA journal_mode = WAL;");
instance.run("PRAGMA foreign_keys = true;"); instance.run("PRAGMA foreign_keys = true;");
instance.run("PRAGMA optimize=0x10002;");
} }
return instance; return instance;

View File

@@ -0,0 +1,58 @@
import { unified, type Processor } from "unified";
import type { Root } from 'hast';
import RemarkParse from "remark-parse";
import RemarkRehype from 'remark-rehype';
import RemarkOfm from 'remark-ofm';
import RemarkGfm from 'remark-gfm';
import RemarkBreaks from 'remark-breaks';
import RemarkFrontmatter from 'remark-frontmatter';
import StripMarkdown from 'strip-markdown';
import RemarkStringify from 'remark-stringify';
interface Parser
{
parse: (md: string) => Promise<Root>;
parseSync: (md: string) => Root;
text: (md: string) => string;
}
export default function useMarkdown(): Parser
{
let processor: Processor, processorText: Processor;
const parse = (markdown: string) => {
if (!processor)
{
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]);
processor.use(RemarkRehype);
}
const processed = processor.run(processor.parse(markdown)) as Promise<Root>;
return processed;
}
const parseSync = (markdown: string) => {
if (!processor)
{
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]);
processor.use(RemarkRehype);
}
const processed = processor.runSync(processor.parse(markdown)) as Root;
return processed;
}
const text = (markdown: string) => {
if (!processorText)
{
processorText = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter ]);
processorText.use(StripMarkdown, { remove: [ 'comment', 'tag', 'callout' ] });
processorText.use(RemarkStringify);
}
const processed = processorText.processSync(markdown);
return String(processed);
}
return { parse, parseSync, text };
}

View File

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

View File

@@ -1,9 +1,7 @@
import type { UserSession, UserSessionComposable } from '~/types/auth' import type { UserSession, UserSessionComposable } from '~/types/auth'
import { useContent } from './useContent'
const useSessionState = () => useState<UserSession>('nuxt-session', () => ({})) const useSessionState = () => useState<UserSession>('nuxt-session', () => ({}))
const useAuthReadyState = () => useState('nuxt-auth-ready', () => false) const useAuthReadyState = () => useState('nuxt-auth-ready', () => false)
const useContentFetch = (force: boolean) => useContent().fetch(force);
/** /**
* Composable to get back the user session and utils around it. * Composable to get back the user session and utils around it.

View File

@@ -1,102 +1,114 @@
import { relations } from 'drizzle-orm'; import { relations, sql } from 'drizzle-orm';
import { int, text, sqliteTable, primaryKey, blob } from 'drizzle-orm/sqlite-core'; import { int, text, sqliteTable as table, primaryKey, blob } from 'drizzle-orm/sqlite-core';
import { ABILITIES, MAIN_STATS } from '~/shared/character'; import type { ItemState } from '~/types/character';
export const usersTable = sqliteTable("users", { export const usersTable = table("users", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(), username: text().notNull().unique(),
email: text().notNull().unique(), email: text().notNull().unique(),
hash: text().notNull().unique(), hash: text().notNull().unique(),
state: int().notNull().default(0), state: int().notNull().default(0),
}); });
export const usersDataTable = table("users_data", {
export const usersDataTable = sqliteTable("users_data", {
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
lastTimestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), lastTimestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
logCount: int().notNull().default(0),
}); });
export const userSessionsTable = table("user_sessions", {
export const userSessionsTable = sqliteTable("user_sessions", {
id: int().notNull(), id: int().notNull(),
user_id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), user_id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}, (table) => [primaryKey({ columns: [table.id, table.user_id] })]); }, (table) => [primaryKey({ columns: [table.id, table.user_id] })]);
export const userPermissionsTable = table("user_permissions", {
export const userPermissionsTable = sqliteTable("user_permissions", {
id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
permission: text().notNull(), permission: text().notNull(),
}, (table) => [primaryKey({ columns: [table.id, table.permission] })]); }, (table) => [primaryKey({ columns: [table.id, table.permission] })]);
export const explorerContentTable = sqliteTable("explorer_content", { export const projectFilesTable = table("project_files", {
path: text().primaryKey(), id: text().primaryKey(),
path: text().notNull().unique(),
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
title: text().notNull(), title: text().notNull(),
type: text({ enum: ['file', 'folder', 'markdown', 'canvas', 'map'] }).notNull(), type: text({ enum: ['file', 'folder', 'markdown', 'canvas', 'map'] }).notNull(),
content: blob({ mode: 'buffer' }),
navigable: int({ mode: 'boolean' }).notNull().default(true), navigable: int({ mode: 'boolean' }).notNull().default(true),
private: int({ mode: 'boolean' }).notNull().default(false), private: int({ mode: 'boolean' }).notNull().default(false),
order: int().notNull(), order: int().notNull(),
visit: int().notNull().default(0),
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}); });
export const projectContentTable = table("project_content", {
id: text().primaryKey(),
content: blob({ mode: 'buffer' }),
});
export const emailValidationTable = sqliteTable("email_validation", { export const emailValidationTable = table("email_validation", {
id: text().primaryKey(), id: text().primaryKey(),
timestamp: int({ mode: 'timestamp' }).notNull(), timestamp: int({ mode: 'timestamp' }).notNull(),
}) })
export const characterTable = sqliteTable("character", { export const characterTable = table("character", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(), name: text().notNull(),
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
people: int().notNull(), people: text().notNull(),
level: int().notNull().default(1), level: int().notNull().default(1),
aspect: int(), variables: text({ mode: 'json' }).notNull().default('{"health": 0,"mana": 0,"spells": [],"items": [],"exhaustion": 0,"sickness": [],"poisons": []}'),
notes: text(), aspect: text().notNull(),
health: int().notNull().default(0), public_notes: text(),
mana: int().notNull().default(0), private_notes: text(),
visibility: text({ enum: ['private', 'public'] }).notNull().default('private'), visibility: text({ enum: ['private', 'public'] }).notNull().default('private'),
thumbnail: blob(), thumbnail: blob(),
}); });
export const characterTrainingTable = table("character_training", {
export const characterTrainingTable = sqliteTable("character_training", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
stat: text({ enum: MAIN_STATS }).notNull(), stat: text({ enum: ["strength","dexterity","constitution","intelligence","curiosity","charisma","psyche"] }).notNull(),
level: int().notNull(), level: int().notNull(),
choice: int().notNull(), choice: int().notNull(),
}, (table) => [primaryKey({ columns: [table.character, table.stat, table.level] })]); }, (table) => [primaryKey({ columns: [table.character, table.stat, table.level] })]);
export const characterLevelingTable = table("character_leveling", {
export const characterLevelingTable = sqliteTable("character_leveling", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
level: int().notNull(), level: int().notNull(),
choice: int().notNull(), choice: int().notNull(),
}, (table) => [primaryKey({ columns: [table.character, table.level] })]); }, (table) => [primaryKey({ columns: [table.character, table.level] })]);
export const characterAbilitiesTable = table("character_abilities", {
export const characterAbilitiesTable = sqliteTable("character_abilities", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
ability: text({ enum: ABILITIES }).notNull(), ability: text({ enum: ["athletics","acrobatics","intimidation","sleightofhand","stealth","survival","investigation","history","religion","arcana","understanding","perception","performance","medecine","persuasion","animalhandling","deception"] }).notNull(),
value: int().notNull().default(0), value: int().notNull().default(0),
max: int().notNull().default(0), max: int().notNull().default(0),
}, (table) => [primaryKey({ columns: [table.character, table.ability] })]); }, (table) => [primaryKey({ columns: [table.character, table.ability] })]);
export const characterChoicesTable = table("character_choices", {
export const characterModifiersTable = sqliteTable("character_modifiers", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
modifier: text({ enum: MAIN_STATS }).notNull(), id: text().notNull(),
value: int().notNull().default(0), choice: int().notNull(),
}, (table) => [primaryKey({ columns: [table.character, table.modifier] })]); }, (table) => [primaryKey({ columns: [table.character, table.id, table.choice] })]);
export const characterSpellsTable = sqliteTable("character_spell", { export const campaignTable = table("campaign", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), id: int().primaryKey({ autoIncrement: true }),
value: text().notNull(), name: text().notNull(),
}, (table) => [primaryKey({ columns: [table.character, table.value] })]); 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({}).$type<{}>(),
items: text({ mode: 'json' }).default([]).$type<ItemState[]>(),
money: int().default(0),
public_notes: text().default(''),
dm_notes: text().default(''),
});
export const campaignMembersTable = table("campaign_members", {
id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
user: int().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' })
}, (table) => [primaryKey({ columns: [table.id, table.user] })]);
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 usersRelation = relations(usersTable, ({ one, many }) => ({ export const usersRelation = relations(usersTable, ({ one, many }) => ({
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }), data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
session: many(userSessionsTable), session: many(userSessionsTable),
permission: many(userPermissionsTable), permission: many(userPermissionsTable),
content: many(explorerContentTable), files: many(projectFilesTable),
characters: many(characterTable),
})); }));
export const usersDataRelation = relations(usersDataTable, ({ one }) => ({ export const usersDataRelation = relations(usersDataTable, ({ one }) => ({
users: one(usersTable, { fields: [usersDataTable.id], references: [usersTable.id], }), users: one(usersTable, { fields: [usersDataTable.id], references: [usersTable.id], }),
@@ -107,18 +119,18 @@ export const userSessionsRelation = relations(userSessionsTable, ({ one }) => ({
export const userPermissionsRelation = relations(userPermissionsTable, ({ one }) => ({ export const userPermissionsRelation = relations(userPermissionsTable, ({ one }) => ({
users: one(usersTable, { fields: [userPermissionsTable.id], references: [usersTable.id], }), users: one(usersTable, { fields: [userPermissionsTable.id], references: [usersTable.id], }),
})); }));
export const explorerContentRelation = relations(explorerContentTable, ({ one }) => ({ export const projectFilesRelation = relations(projectFilesTable, ({ one }) => ({
users: one(usersTable, { fields: [explorerContentTable.owner], references: [usersTable.id], }), users: one(usersTable, { fields: [projectFilesTable.owner], references: [usersTable.id], }),
})); }));
export const characterRelation = relations(characterTable, ({ one, many }) => ({ export const characterRelation = relations(characterTable, ({ one, many }) => ({
user: one(usersTable, { fields: [characterTable.owner], references: [usersTable.id], }), user: one(usersTable, { fields: [characterTable.owner], references: [usersTable.id], }),
training: many(characterTrainingTable), training: many(characterTrainingTable),
levels: many(characterLevelingTable), levels: many(characterLevelingTable),
abilities: many(characterAbilitiesTable), abilities: many(characterAbilitiesTable),
modifiers: many(characterModifiersTable), choices: many(characterChoicesTable),
spells: many(characterSpellsTable) campaign: one(campaignCharactersTable, { fields: [characterTable.id], references: [campaignCharactersTable.character], }),
})); }));
export const characterTrainingRelation = relations(characterTrainingTable, ({ one }) => ({ export const characterTrainingRelation = relations(characterTrainingTable, ({ one }) => ({
character: one(characterTable, { fields: [characterTrainingTable.character], references: [characterTable.id] }) character: one(characterTable, { fields: [characterTrainingTable.character], references: [characterTable.id] })
})); }));
@@ -128,9 +140,20 @@ export const characterLevelingRelation = relations(characterLevelingTable, ({ on
export const characterAbilitiesRelation = relations(characterAbilitiesTable, ({ one }) => ({ export const characterAbilitiesRelation = relations(characterAbilitiesTable, ({ one }) => ({
character: one(characterTable, { fields: [characterAbilitiesTable.character], references: [characterTable.id] }) character: one(characterTable, { fields: [characterAbilitiesTable.character], references: [characterTable.id] })
})); }));
export const characterModifierRelation = relations(characterModifiersTable, ({ one }) => ({ export const characterChoicesRelation = relations(characterChoicesTable, ({ one }) => ({
character: one(characterTable, { fields: [characterModifiersTable.character], references: [characterTable.id] }) character: one(characterTable, { fields: [characterChoicesTable.character], references: [characterTable.id] })
})); }));
export const characterSpellsRelation = relations(characterSpellsTable, ({ one }) => ({
character: one(characterTable, { fields: [characterSpellsTable.character], references: [characterTable.id] }) export const campaignRelation = relations(campaignTable, ({ one, many }) => ({
members: many(campaignMembersTable),
characters: many(campaignCharactersTable),
owner: one(usersTable, { fields: [campaignTable.owner], references: [usersTable.id], }),
}));
export const campaignMembersRelation = relations(campaignMembersTable, ({ one }) => ({
campaign: one(campaignTable, { fields: [campaignMembersTable.id], references: [campaignTable.id], }),
member: one(usersTable, { fields: [campaignMembersTable.user], references: [usersTable.id], })
}));
export const campaignCharacterRelation = relations(campaignCharactersTable, ({ one }) => ({
campaign: one(campaignTable, { fields: [campaignCharactersTable.id], references: [campaignTable.id], }),
character: one(characterTable, { fields: [campaignCharactersTable.character], references: [characterTable.id], }),
})); }));

28
app/error.vue Normal file
View File

@@ -0,0 +1,28 @@
<template>
<Head>
<Title>d[any] - Erreur {{ error?.statusCode }}</Title>
</Head>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden justify-center items-center flex-col gap-4">
<NuxtRouteAnnouncer/>
<div class="flex gap-4 items-center">
<Icon icon="si:error-line" class="w-12 h-12 text-light-60 dark:text-dark-60"/>
<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]
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>
</div>
</template>
<script setup lang="ts">
import type { NuxtError } from '#app';
import { Icon } from '@iconify/vue';
const props = defineProps({
error: Object as () => NuxtError
});
const handleError = () => clearError({ redirect: '/' });
</script>

108
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,108 @@
<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>
<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>
</div>
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { TreeDOM } from '#shared/tree';
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';
const open = ref(false);
let tree: TreeDOM | undefined;
const { loggedIn, user } = useUserSession();
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;
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)
{
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 } }, [
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([
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,
], { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, active: 'text-accent-blue' }, item.path ? { name: 'explore-path', params: { path: item.path } } : undefined )]);
}, (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);
})
}
})
onUnmounted(() => {
unmount();
})
</script>

View File

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

View File

@@ -31,8 +31,10 @@
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { format, iconByType } from '~/shared/general.util'; import { format } from '~~/shared/general';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { iconByType } from '~~/shared/content';
import { Icon } from '@iconify/vue';
import { Toaster } from '~~/shared/components';
interface File interface File
{ {
@@ -68,8 +70,6 @@ definePageMeta({
rights: ['admin'], rights: ['admin'],
}); });
const toaster = useToast();
const { data: users } = useFetch('/api/admin/users', { const { data: users } = useFetch('/api/admin/users', {
transform: (users) => { transform: (users) => {
//@ts-ignore //@ts-ignore
@@ -124,13 +124,13 @@ async function editPermissions(user: User)
body: permissionCopy.value, body: permissionCopy.value,
}); });
user.permission = permissionCopy.value; user.permission = permissionCopy.value;
toaster.add({ Toaster.add({
duration: 10000, type: 'success', content: 'Permissions mises à jour.', timer: true, duration: 10000, type: 'success', content: 'Permissions mises à jour.', timer: true,
}); });
} }
catch(e) catch(e)
{ {
toaster.add({ Toaster.add({
duration: 10000, type: 'error', content: (e as any).message, timer: true, duration: 10000, type: 'error', content: (e as any).message, timer: true,
}); });
} }
@@ -145,13 +145,13 @@ async function logout(user: User)
user.session.length = 0; user.session.length = 0;
toaster.add({ Toaster.add({
duration: 10000, type: 'success', content: 'L\'utilisateur vient d\'être déconnecté.', timer: true, duration: 10000, type: 'success', content: 'L\'utilisateur vient d\'être déconnecté.', timer: true,
}); });
} }
catch(e) catch(e)
{ {
toaster.add({ Toaster.add({
duration: 10000, type: 'error', content: (e as any).message, timer: true, duration: 10000, type: 'error', content: (e as any).message, timer: true,
}); });
} }
@@ -164,8 +164,8 @@ async function logout(user: User)
</Head> </Head>
<div class="flex flex-1 flex-col p-4"> <div class="flex flex-1 flex-col p-4">
<div class="flex flex-row justify-between items-center"> <div class="flex flex-row justify-between items-center">
<ProseH2 class="text-center flex-1">Administration</ProseH2> <h2 class="text-center flex-1 text-2xl font-bold">Administration</h2>
<Button><NuxtLink :to="{ name: 'admin-jobs' }">Jobs</NuxtLink></Button> <NuxtLink :to="{ name: 'admin-jobs' }"><Button>Jobs</Button></NuxtLink>
</div> </div>
<div class="flex flex-1 w-full justify-center items-stretch flex-row gap-4"> <div class="flex flex-1 w-full justify-center items-stretch flex-row gap-4">
<div class="flex-1"> <div class="flex-1">

View File

@@ -13,17 +13,17 @@ const schemaList: Record<string, z.ZodObject<any> | null> = {
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { z } from 'zod'; import { z } from 'zod/v4';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
import { Toaster } from '~~/shared/components';
definePageMeta({ definePageMeta({
rights: ['admin'], rights: ['admin'],
}) })
const job = ref<string>(''); const job = ref<string>('');
const toaster = useToast();
const payload = reactive<Record<string, any>>({ const payload = reactive<Record<string, any>>({
data: JSON.stringify({ username: "Peaceultime", id: 1, timestamp: Date.now() }), data: JSON.stringify({ username: "Peaceultime", id: 1, userId: 1, timestamp: Date.now() }),
to: 'clem31470@gmail.com', to: 'clem31470@gmail.com',
}); });
const data = ref(), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), success = ref(false), error = ref<Error | null>(); const data = ref(), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), success = ref(false), error = ref<Error | null>();
@@ -51,7 +51,7 @@ async function fetch()
error.value = null; error.value = null;
success.value = true; success.value = true;
toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, }); Toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, });
} }
catch(e) catch(e)
{ {
@@ -59,7 +59,7 @@ async function fetch()
error.value = e as Error; error.value = e as Error;
success.value = false; success.value = false;
toaster.add({ duration: 10000, content: error.value.message, type: 'error', timer: true, }); Toaster.add({ duration: 10000, content: error.value.message, type: 'error', timer: true, });
} }
} }
</script> </script>
@@ -71,7 +71,7 @@ async function fetch()
<div class="flex flex-col justify-start items-center p-4"> <div class="flex flex-col justify-start items-center p-4">
<div class="flex flex-row justify-between items-center gap-8"> <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 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>
<ProseH2 class="text-center flex-1">Administration</ProseH2> <h2 class="text-center flex-1 text-2xl font-bold">Administration</h2>
</div> </div>
<div class="flex flex-row w-full gap-8"> <div class="flex flex-row w-full gap-8">
<Select label="Job" v-model="job"> <Select label="Job" v-model="job">

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { unifySlug } from '~~/shared/general';
import { CampaignSheet } from '~~/shared/campaign';
definePageMeta({
requiresAuth: true,
});
const id = unifySlug(useRoute().params.id ?? '');
const { user } = useUserSession();
const container = useTemplateRef('container');
onMounted(() => {
queueMicrotask(() => {
if(container.value && id)
{
const campaign = new CampaignSheet(id, user);
container.value.appendChild(campaign.container);
onUnmounted(() => {
campaign.ws?.close();
})
}
});
})
</script>
<template>
<div class="flex flex-1 w-full h-full items-start justify-center" ref="container"></div>
</template>

View File

@@ -0,0 +1 @@
<template></template>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { Toaster } from '~~/shared/components';
definePageMeta({
requiresAuth: true,
});
const { user, loggedIn } = useUserSession();
const { data: campaigns, error, status } = await useFetch(`/api/campaign`);
const archives = computed(() => campaigns.value?.filter(e => e.status === 'ARCHIVED'));
const valids = computed(() => campaigns.value?.filter(e => e.status !== 'ARCHIVED'));
async function leaveCampaign(id: number)
{
try
{
await useRequestFetch()(`/api/campaign/${id}/leave`, { method: 'POST', });
campaigns.value = campaigns.value?.filter(e => e.id !== id);
}
catch(e)
{
Toaster.add({ duration: 10000, content: (e as Error).message ?? e, title: 'Une erreur est survenue.', type: 'error', timer: true, });
}
}
async function removeCampaign(id: number)
{
try
{
await useRequestFetch()(`/api/campaign/${id}`, { method: 'DELETE', });
campaigns.value = campaigns.value?.filter(e => e.id !== id);
}
catch(e)
{
Toaster.add({ duration: 10000, content: (e as Error).message ?? e, title: 'Une erreur est survenue.', type: 'error', timer: true, });
}
}
function create()
{
useRequestFetch()('/api/campaign', {
method: 'POST',
body: { name: 'Margooning', public_notes: '', dm_notes: '', settings: {} },
}).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>
<template>
<Head>
<Title>d[any] - Mes campagnes</Title>
</Head>
<div class="flex flex-col">
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
<Loading size="large" />
</div>
<template v-else-if="status === 'success' && loggedIn && user">
<div class="flex flex-row items-center w-full"><Button @click="() => create()">Nouvelle campagne</Button></div>
<div v-if="campaigns && campaigns.length > 0" class="flex flex-col gap-4">
<div v-if="valids && valids.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="campaign of valids">
<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 class="flex justify-around items-center py-2 px-4 gap-4">
<AlertDialogRoot>
<AlertDialogTrigger>
<span class="text-sm font-bold text-light-red dark:text-dark-red">{{ user.id !== campaign.owner.id ? 'Quitter' : 'Supprimer' }}</span>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
<AlertDialogContent
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
<AlertDialogTitle class="text-3xl font-light relative -top-2">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>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</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">
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="campaign of archives">
<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 class="flex justify-around items-center py-2 px-4 gap-4">
<AlertDialogRoot>
<AlertDialogTrigger>
<span class="text-sm font-bold text-light-red dark:text-dark-red">{{ user.id !== campaign.owner.id ? 'Quitter' : 'Supprimer' }}</span>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
<AlertDialogContent
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
<AlertDialogTitle class="text-3xl font-light relative -top-2">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>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</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]
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]
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> -->
</div>
</template>
<div v-else>
<span>Erreur de chargement</span>
<span>{{ error?.message }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { CharacterBuilder } from '~~/shared/character';
import { unifySlug } from '~~/shared/general';
definePageMeta({
requiresAuth: true,
validState: true,
});
const id = unifySlug(useRouter().currentRoute.value.params.id ?? "new");
const container = useTemplateRef('container');
onMounted(() => {
queueMicrotask(() => {
if(container.value)
{
const builder = new CharacterBuilder(container.value, id === 'new' ? undefined : id);
useShortcuts({
"Meta_S": () => builder.save(false),
});
}
});
})
</script>
<template>
<div class="flex flex-1 max-w-full flex-col align-center" ref="container"></div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import characterConfig from '#shared/character-config.json';
import { unifySlug } from '~~/shared/general';
import type { CharacterConfig } from '~/types/character';
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
text-light-blue dark:text-dark-blue border-light-blue dark:border-dark-blue bg-light-blue dark:bg-dark-blue
text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow
text-light-orange dark:text-dark-orange border-light-orange dark:border-dark-orange bg-light-orange dark:bg-dark-orange
text-light-indigo dark:text-dark-indigo border-light-indigo dark:border-dark-indigo bg-light-indigo dark:bg-dark-indigo
text-light-lime dark:text-dark-lime border-light-lime dark:border-dark-lime bg-light-lime dark:bg-dark-lime
text-light-green dark:text-dark-green border-light-green dark:border-dark-green bg-light-green dark:bg-dark-green
text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow
text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-purple bg-light-purple dark:bg-dark-purple
*/
const config = characterConfig as CharacterConfig;
const id = useRouter().currentRoute.value.params.id ? unifySlug(useRouter().currentRoute.value.params.id!) : undefined;
const { user } = useUserSession();
const container = useTemplateRef('container');
onMounted(() => {
queueMicrotask(() => {
if(container.value && id)
{
const character = new CharacterSheet(id, user);
container.value.appendChild(character.container);
onUnmounted(() => {
character.ws?.close();
})
}
});
});
</script>
<template>
<div class="flex flex-1 w-full h-full items-start justify-center" ref="container"></div>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import characterConfig from '#shared/character-config.json';
import { Toaster } from '~~/shared/components';
import type { CharacterConfig } from '~/types/character';
definePageMeta({
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";
await useRequestFetch()(`/api/character/${id}`, { method: 'delete' });
status.value = "success";
Toaster.add({ content: 'Personnage supprimé', type: 'info', duration: 25000, timer: true, });
characters.value = characters.value?.filter(e => e.id !== id);
}
async function duplicateCharacter(id: number)
{
status.value = "pending";
const newId = await useRequestFetch()(`/api/character/${id}/duplicate`, { method: 'post' });
status.value = "success";
Toaster.add({ content: 'Personnage dupliqué', type: 'info', duration: 25000, timer: true, });
useRouter().push({ name: 'character-id', params: { id: newId } });
}
</script>
<template>
<Head>
<Title>d[any] - Mes personnages</Title>
</Head>
<div class="flex flex-col">
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
<Loading size="large" />
</div>
<template v-else-if="status === 'success'">
<div v-if="characters && characters.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
<NuxtLink :to="{ name: 'character-id', params: { id: character.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2">
<div class="flex flex-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">{{ character.name }}</span>
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
<div class="flex flex-row flex-1 items-stretch gap-4">
<span class="text-sm">Niveau {{ character.level }}</span>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<span class="text-sm italic">{{ config.peoples[character.people!]?.name }}</span>
</div>
</div>
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
</div>
</NuxtLink>
<div class="flex justify-around items-center py-2 px-4 gap-4">
<NuxtLink :to="{ name: 'character-id-edit', params: { id: character.id } }" class="text-sm font-bold cursor-pointer hover:text-accent-blue">Editer</NuxtLink>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<NuxtLink @click="duplicateCharacter(character.id)" class="text-sm font-bold cursor-pointer hover:text-accent-blue">Dupliquer</NuxtLink>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<AlertDialogRoot>
<AlertDialogTrigger>
<span class="text-sm font-bold text-light-red dark:text-dark-red">Supprimer</span>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
<AlertDialogContent
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
<AlertDialogTitle class="text-3xl font-light relative -top-2">Supprimer {{ character.name }} ?</AlertDialogTitle>
<div class="flex flex-1 justify-end gap-4">
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
<AlertDialogAction asChild><Button @click="() => deleteCharacter(character.id)" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</div>
</div>
<div v-else class="flex flex-col gap-2 items-center flex-1">
<span class="text-lg font-bold">Vous n'avez pas encore de personnage</span>
<NuxtLink v-if="user && user.state === 1" class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
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>
<div v-else>Veuillez validez votre adresse mail pour pouvoir créer des personnages.</div>
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
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>
</div>
</template>
<div v-else>
<span>Erreur de chargement</span>
<span>{{ error?.message }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
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>
<Head>
<Title>d[any] - Liste des personnages</Title>
</Head>
<div class="flex flex-col">
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
<Loading size="large" />
</div>
<template v-else-if="status === 'success'">
<div v-if="characters && characters.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
<NuxtLink :to="{ name: 'character-id', params: { id: character.id } }" class="group bg-light-10 dark:bg-dark-10 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">{{ character.name }}</span>
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
<div class="flex flex-row flex-1 items-stretch gap-4">
<span class="text-sm">Niveau {{ character.level }}</span>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<span class="text-sm italic">{{ config.peoples[character.people!]?.name }}</span>
</div>
</div>
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
</div>
</NuxtLink>
</div>
</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>
<template v-if="user && user.state === 1">
Soyez le premier à partager vos créations !
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
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>
<div v-else>Veuillez valider votre adresse mail pour pouvoir créer des personnages.</div>
</div>
</template>
<div v-else>
<span>Erreur de chargement</span>
<span>{{ error?.message }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { HomebrewBuilder } from '#shared/feature';
definePageMeta({
requiresAuth: true,
});
const container = useTemplateRef('container');
onMounted(() => {
queueMicrotask(() => {
if(container.value)
{
const builder = new HomebrewBuilder(container.value);
}
});
})
</script>
<template>
<Head>
<Title>d[any] - Edition de données</Title>
</Head>
<div ref="container" class="flex flex-1 max-w-full flex-col gap-8 justify-start items-center px-8 w-full"></div>
</template>

View File

@@ -0,0 +1,23 @@
<template>
<div class="flex flex-1 justify-start items-start" ref="element">
<Head>
<Title>d[any] - {{ overview?.title ?? "Erreur" }}</Title>
</Head>
</div>
</template>
<script setup lang="ts">
import { Content } from '~~/shared/content';
import { unifySlug } from '~~/shared/general';
const element = useTemplateRef('element'), overview = ref();
const route = useRouter().currentRoute;
const path = computed(() => unifySlug(route.value.params.path ?? ''));
onMounted(async () => {
if(element.value && path.value)
{
overview.value = await Content.render(element.value, path.value);
}
});
</script>

View File

@@ -0,0 +1,111 @@
<template>
<Head>
<Title>d[any] - Modification</Title>
</Head>
<div class="flex flex-row w-full max-w-full h-full max-h-full xl:-mx-12 xl:-my-8 lg:-mx-8 lg:-my-6 -mx-6 -my-3" 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="tree"></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: '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>
</div>
</div>
<div class="flex flex-1 flex-row max-h-full overflow-hidden" ref="container"></div>
</div>
</div>
</template>
<script setup lang="ts">
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({
rights: ['admin', 'editor'],
layout: 'null',
});
const { user } = useUserSession();
const tree = useTemplateRef('tree'), container = useTemplateRef('container');
let editor: Editor;
function pull()
{
Content.pull().then(e => {
Toaster.add({ type: 'success', content: 'Données mises à jour avec succès.', timer: true, duration: 7500 });
}).catch(e => {
Toaster.add({ type: 'success', content: 'Une erreur est survenue durant la récupération des données.', timer: true, duration: 7500 });
console.error(e);
});
}
function push()
{
const { close } = modal([dom('div', { class: 'flex flex-col gap-4 justify-center items-center' }, [ dom('div', { class: 'text-xl', text: 'Mise à jour des données' }), loading('large') ])], { priority: false, closeWhenOutside: true, });
Content.push().then(e => {
close();
Toaster.add({ type: 'success', content: 'Données mises à jour avec succès.', timer: true, duration: 7500 });
}).catch(e => {
close();
Toaster.add({ type: 'success', content: 'Une erreur est survenue durant l\'enregistrement des données.', timer: true, duration: 7500 });
console.error(e);
});
}
onMounted(async () => {
if(tree.value && container.value)
{
const load = loading('normal');
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'),
])
tree.value.insertBefore(content, load);
editor = new Editor();
Content.ready.then(() => tree.value?.replaceChild(editor.tree.container, load));
container.value.appendChild(editor.container);
}
});
onBeforeUnmount(() => {
editor?.unmount();
});
</script>

View File

@@ -3,8 +3,8 @@
<Title>d[any] - Mentions légales</Title> <Title>d[any] - Mentions légales</Title>
</Head> </Head>
<div class="flex flex-col max-w-[1200px] p-16"> <div class="flex flex-col max-w-[1200px] p-16">
<ProseH3>Mentions Légales</ProseH3> <h3 class="text-xl font-bold">Mentions Légales</h3>
<ProseH4>Collecte et Traitement des Données Personnelles</ProseH4> <h4 class="text-lg font-semibold">Collecte et Traitement des Données Personnelles</h4>
Ce site collecte des données personnelles durant l'inscription et des données anonymes durant la navigation sur Ce site collecte des données personnelles durant l'inscription et des données anonymes durant la navigation sur
le site dans un but de collecte statistiques.<br /> le site dans un but de collecte statistiques.<br />
@@ -12,21 +12,21 @@
suppression de vos données personnelles. <br /> suppression de vos données personnelles. <br />
Pour exercer ces droits, vous pouvez vous rendre dans votre profil et selectionner l'option "Supprimer mon Pour exercer ces droits, vous pouvez vous rendre dans votre profil et selectionner l'option "Supprimer mon
compte" qui garanti une suppression de l'intégralité de vos données personnelles. compte" qui garanti une suppression de l'intégralité de vos données personnelles.<br /><br />
<ProseH4>Utilisation des Cookies</ProseH4> <h4 class="text-lg font-semibold">Utilisation des Cookies</h4>
Ce site utilise des cookies uniquement pour maintenir la connexion des utilisateurs et faciliter leur navigation Ce site utilise des cookies uniquement pour maintenir la connexion des utilisateurs et faciliter leur navigation
lors de chaque visite. Aucune information de suivi ou de profilage n'est réalisée. Ces cookies sont essentiels lors de chaque visite. Aucune information de suivi ou de profilage n'est réalisée. Ces cookies sont essentiels
au fonctionnement du site et ne nécessitent pas de consentement préalable. <br /> au fonctionnement du site et ne nécessitent pas de consentement préalable. <br />
Vous pouvez gérer les cookies en configurant les paramètres de votre navigateur, mais la désactivation de ces Vous pouvez gérer les cookies en configurant les paramètres de votre navigateur, mais la désactivation de ces
cookies pourrait affecter votre expérience de navigation. cookies pourrait affecter votre expérience de navigation.<br /><br />
<ProseH4>Limitation de Responsabilité</ProseH4> <h4 class="text-lg font-semibold">Limitation de Responsabilité</h4>
Les informations publiées sur ce site sont fournies à titre indicatif et peuvent contenir des erreurs. <br /> Les informations publiées sur ce site sont fournies à titre indicatif et peuvent contenir des erreurs. <br />
L'éditeur décline toute responsabilité quant à l'usage qui pourrait être fait de ces informations. L'éditeur décline toute responsabilité quant à l'usage qui pourrait être fait de ces informations.<br /><br />
<ProseH4>Propriété Intellectuelle</ProseH4> <h4 class="text-lg font-semibold">Propriété Intellectuelle</h4>
Tous les contenus présents sur ce site (textes, images, logos, etc.) sont protégés par les lois en vigueur Tous les contenus présents sur ce site (textes, images, logos, etc.) sont protégés par les lois en vigueur
sur la propriété intellectuelle. Toute reproduction ou utilisation de ces contenus sans autorisation préalable sur la propriété intellectuelle. Toute reproduction ou utilisation de ces contenus sans autorisation préalable
est interdite. <br /><br /> est interdite. <br /><br />

45
app/pages/usage.vue Normal file
View File

@@ -0,0 +1,45 @@
<template>
<Head>
<Title>d[any] - Mentions légales</Title>
</Head>
<div class="flex flex-col max-w-[1200px] p-16">
<h3 class="text-xl font-bold">Conditions Générales d'Utilisation du site d-any.com</h3>
<h4 class="text-lg font-semibold py-2">1. Objet</h4>
Le site d-any.com offre un service en ligne dédié au jeu de rôle comprenant une section de règles officielles maintenues par l'administrateur, une section permettant la création de personnages
publics ou privés et une section de campagnes visant à rassembler plusieurs joueurs pour faire interagir leurs personnages. L'utilisation du site implique l'acceptation pleine et entière des présentes conditions. <br/><br/>
<h4 class="text-lg font-semibold py-2">2. Accès et fonctionnement</h4>
L'accès au site est gratuit. L'interaction entre utilisateurs est strictement limitée aux personnages et joueurs participant à une même campagne partagée. Aucun contact direct ni interaction n'est possible en dehors de cette structure.<br/><br/>
<h4 class="text-lg font-semibold py-2">3. Création et gestion des personnages</h4>
Les utilisateurs peuvent créer des personnages publics, visibles par tous les membres des campagnes partagées, ou privés, visibles uniquement par leur créateur.
Les utilisateurs sont responsables du contenu des personnages qu'ils créent. Ils s'engagent à ne pas créer ou publier des personnages portant atteinte à la dignité, contenant des propos discriminatoires, diffamatoires, obscènes ou illicites.
L'administrateur du site se réserve le droit de supprimer ou masquer tout personnage en infraction avec ces règles.<br/><br/>
<h4 class="text-lg font-semibold py-2">4. Règles du jeu</h4>
Les règles officielles du jeu, rédigées et entretenues par l'administrateur, doivent être respectées par tous les utilisateurs dans la création et le déroulement des campagnes.<br/><br/>
<h4 class="text-lg font-semibold py-2">5. Interaction en campagne</h4>
Les communications et interactions entre joueurs et personnages sont strictement limitées aux campagnes partagées.
Toute interaction dans ces cadres doit respecter les règles de respect, de courtoisie et de fair-play.
Tout comportement abusif, harcèlement, propos haineux ou toute forme de contenu illicite est prohibé et pourra entraîner des sanctions, incluant la suppression de comptes ou personnages.<br/><br/>
<h4 class="text-lg font-semibold py-2">6. Propriété intellectuelle</h4>
Les règles, outils, et contenus hébergés sur le site sont la propriété de l'administrateur ou des auteurs respectifs.
Les personnages créés appartiennent à leurs auteurs, sous réserve du respect des droits d'auteur liés au jeu original et de la charte du site.<br/><br/>
<h4 class="text-lg font-semibold py-2">7. Données personnelles</h4>
Les données collectées se limitent à celles nécessaires au fonctionnement du site. Toute donnée personnelle est traitée conformément à la réglementation en vigueur et peut être modifiée ou supprimée sur demande.<br/><br/>
<h4 class="text-lg font-semibold py-2">8. Responsabilité</h4>
L'administrateur ne pourra être tenu responsable des usages faits par les utilisateurs des personnages publics ou des interactions au sein des campagnes. L'éditeur décline toute responsabilité en cas d'abus
entre joueurs ou de contenu illégal diffusé par un utilisateur.<br/><br/>
<h4 class="text-lg font-semibold py-2">9. Modification des conditions</h4>
Ces conditions peuvent être modifiées à tout moment par l'administrateur. Les utilisateurs seront informés des modifications via le site et l'usage continu vaudra acceptation des nouvelles conditions.<br/><br/>
<h4 class="text-lg font-semibold py-2">10. Droit applicable</h4>
Les présentes conditions sont soumises au droit français. Tout litige sera porté devant les tribunaux compétents.<br/><br/>
<div class="py-32"></div>
</div>
</template>

View File

@@ -3,7 +3,7 @@
<Title>d[any] - Validation de votre adresse mail</Title> <Title>d[any] - Validation de votre adresse mail</Title>
</Head> </Head>
<div class="flex flex-col justify-center items-center"> <div class="flex flex-col justify-center items-center">
<ProseH2>Votre compte a été validé ! 🎉</ProseH2> <h2 class="text-2xl font-bold">Votre compte a été validé ! 🎉</h2>
<div class="flex flex-row gap-8"> <div class="flex flex-row gap-8">
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'user-login', replace: true }">Se connecter</NuxtLink></Button> <Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'user-login', replace: true }">Se connecter</NuxtLink></Button>
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'index', replace: true }">Retourner à l'accueil</NuxtLink></Button> <Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'index', replace: true }">Retourner à l'accueil</NuxtLink></Button>

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 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 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>
<ProseH4>Reinitialisation de mon mot de passe</ProseH4> <h4 class="text-xl font-bold">Reinitialisation de mon mot de passe</h4>
</div> </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" autocomplete="username" v-model="email"/> <TextInput type="text" label="Utilisateur ou email" autocomplete="username" v-model="email"/>
@@ -18,15 +18,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
definePageMeta({ definePageMeta({
layout: 'login', layout: 'login',
usersGoesTo: '/user/profile', usersGoesTo: '/user/profile',
}); });
const toaster = useToast();
const email = ref(''), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'); const email = ref(''), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
async function submit() async function submit()

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 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 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>
<ProseH4>Reinitialisation de mon mot de passe</ProseH4> <h4 class="text-center flex-1 text-xl font-bold">Reinitialisation de mon mot de passe</h4>
</div> </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="password" label="Nouveau mot de passe" autocomplete="newPassword" v-model="newPasswd" :class="{ '!border-light-red !dark:border-dark-red': error }"/> <TextInput type="password" label="Nouveau mot de passe" autocomplete="newPassword" v-model="newPasswd" :class="{ '!border-light-red !dark:border-dark-red': error }"/>
@@ -24,7 +24,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
import { Toaster } from '~~/shared/components';
definePageMeta({ definePageMeta({
layout: 'login', layout: 'login',
@@ -33,7 +34,6 @@ definePageMeta({
const query = useRouter().currentRoute.value.query; const query = useRouter().currentRoute.value.query;
const toaster = useToast();
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), manualError = ref(false); const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), manualError = ref(false);
const oldPasswd = ref(''), newPasswd = ref(''), repeatPasswd = ref(''); const oldPasswd = ref(''), newPasswd = ref(''), repeatPasswd = ref('');
@@ -70,7 +70,7 @@ async function submit()
{ {
status.value = 'success'; status.value = 'success';
toaster.add({ content: 'Votre mot de passe a été modifié avec succès.', duration: 10000, timer: true, type: 'success' }); Toaster.add({ content: 'Votre mot de passe a été modifié avec succès.', duration: 10000, timer: true, type: 'success' });
useRouter().push({ name: 'user-login' }); useRouter().push({ name: 'user-login' });
} }
else else
@@ -81,7 +81,7 @@ async function submit()
status.value = 'error'; status.value = 'error';
const err = e as any; const err = e as any;
toaster.add({ content: err?.data?.message ?? err?.message ?? 'Erreur inconnue', duration: 10000, timer: true, type: 'error' }); Toaster.add({ content: err?.data?.message ?? err?.message ?? 'Erreur inconnue', duration: 10000, timer: true, type: 'error' });
} }
} }
</script> </script>

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 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 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>
<ProseH4>Modification de mon mot de passe</ProseH4> <h4 class="text-center flex-1 text-xl font-bold">Modification de mon mot de passe</h4>
</div> </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="password" label="Ancien mot de passe" name="old-password" autocomplete="current-password" v-model="oldPasswd"/> <TextInput type="password" label="Ancien mot de passe" name="old-password" autocomplete="current-password" v-model="oldPasswd"/>
@@ -25,14 +25,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
import { Toaster } from '~~/shared/components';
definePageMeta({ definePageMeta({
layout: 'login', layout: 'login',
guestsGoesTo: '/user/login', requiresAuth: true,
}); });
const toaster = useToast();
const { user } = useUserSession(); const { user } = useUserSession();
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), manualError = ref(false); const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), manualError = ref(false);
const oldPasswd = ref(''), newPasswd = ref(''), repeatPasswd = ref(''); const oldPasswd = ref(''), newPasswd = ref(''), repeatPasswd = ref('');
@@ -70,19 +70,19 @@ async function submit()
{ {
status.value = 'success'; status.value = 'success';
toaster.add({ content: 'Votre mot de passe a été modifié avec succès.', duration: 10000, timer: true, type: 'success' }); Toaster.add({ content: 'Votre mot de passe a été modifié avec succès.', duration: 10000, timer: true, type: 'success' });
useRouter().push({ name: 'user-profile' }); useRouter().push({ name: 'user-profile' });
} }
else else
{ {
status.value = 'error'; status.value = 'error';
toaster.add({ content: result.error ?? 'Erreur inconnue', duration: 10000, timer: true, type: 'error' }); Toaster.add({ content: result.error ?? 'Erreur inconnue', duration: 10000, timer: true, type: 'error' });
} }
} catch(e) { } catch(e) {
status.value = 'error'; status.value = 'error';
toaster.add({ content: (e as Error).message ?? e, duration: 10000, timer: true, type: 'error' }); Toaster.add({ content: (e as Error).message ?? e, duration: 10000, timer: true, type: 'error' });
} }
} }
</script> </script>

View File

@@ -5,9 +5,9 @@
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 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 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>
<ProseH4>Connexion</ProseH4> <h4 class="text-xl font-bold">Connexion</h4>
</div> </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="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"/> <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> <Button type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Se connecter</Button>
@@ -18,17 +18,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ZodError } from 'zod'; import type { ZodError } from 'zod/v4';
import { schema, type Login } from '~/schemas/login'; import { schema, type Login } from '~/schemas/login';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
import { Toaster } from '~~/shared/components';
definePageMeta({ definePageMeta({
layout: 'login', layout: 'login',
usersGoesTo: '/user/profile', usersGoesTo: '/user/profile',
}); });
const { add: addToast, clear: clearToasts } = useToast();
const state = reactive<Login>({ const state = reactive<Login>({
usernameOrEmail: '', usernameOrEmail: '',
password: '' password: ''
@@ -42,14 +41,12 @@ const { data: result, status, error, refresh } = await useFetch('/api/auth/login
ignoreResponseError: true, ignoreResponseError: true,
}) })
const toastMessage = ref('');
async function submit() async function submit()
{ {
if(state.usernameOrEmail === "") if(state.usernameOrEmail === "")
return addToast({ content: 'Veuillez saisir un nom d\'utilisateur ou un email', timer: true, duration: 10000 }); return Toaster.add({ content: 'Veuillez saisir un nom d\'utilisateur ou un email', timer: true, duration: 10000 });
if(state.password === "") if(state.password === "")
return addToast({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 }); return Toaster.add({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 });
const data = schema.safeParse(state); const data = schema.safeParse(state);
@@ -64,9 +61,12 @@ async function submit()
} }
else if(status.value === 'success' && login.success) else if(status.value === 'success' && login.success)
{ {
clearToasts(); Toaster.clear();
addToast({ duration: 10000, content: 'Vous êtes maintenant connecté', timer: true, type: 'success' }); Toaster.add({ duration: 10000, content: 'Vous êtes maintenant connecté', timer: true, type: 'success' });
await navigateTo('/user/profile');
const router = useRouter();
const target = router.currentRoute.value.query?.t as string | undefined;
router.push(target ? decodeURIComponent(target) : { name: 'user-profile' });
} }
} }
else else
@@ -85,12 +85,12 @@ function handleErrors(error: Error | ZodError)
{ {
for(const err of (error as ZodError).issues) for(const err of (error as ZodError).issues)
{ {
return addToast({ content: err.message, timer: true, duration: 10000, type: 'error' }); return Toaster.add({ content: err.message, timer: true, duration: 10000, type: 'error' });
} }
} }
else else
{ {
return addToast({ content: error?.message ?? 'Une erreur est survenue', timer: true, duration: 10000, type: 'error' }); return Toaster.add({ content: error?.message ?? 'Une erreur est survenue', timer: true, duration: 10000, type: 'error' });
} }
} }
</script> </script>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { hasPermissions } from "#shared/auth.util"; import { hasPermissions } from "#shared/auth";
import { Toaster } from '~~/shared/components';
definePageMeta({ definePageMeta({
guestsGoesTo: '/user/login', requiresAuth: true,
}) })
const { user, clear } = useUserSession(); const { user, clear } = useUserSession();
const toaster = useToast();
const loading = ref<boolean>(false); const loading = ref<boolean>(false);
async function revalidateUser() async function revalidateUser()
@@ -15,7 +15,7 @@ async function revalidateUser()
method: 'post' method: 'post'
}); });
loading.value = false; loading.value = false;
toaster.add({ closeable: false, duration: 10000, timer: true, content: 'Un mail vous a été envoyé.', type: 'info' }); Toaster.add({ closeable: false, duration: 10000, timer: true, content: 'Un mail vous a été envoyé.', type: 'info' });
} }
async function deleteUser() async function deleteUser()
{ {
@@ -38,8 +38,8 @@ async function deleteUser()
<div class="flex gap-4"> <div class="flex gap-4">
<Avatar icon="radix-icons:person" :src="`/users/${user?.id}.medium.jpg`" class="w-32 h-32" /> <Avatar icon="radix-icons:person" :src="`/users/${user?.id}.medium.jpg`" class="w-32 h-32" />
<div class="flex flex-col items-start"> <div class="flex flex-col items-start">
<ProseH5>{{ user.username }}</ProseH5> <h4 class="text-xl font-bold">{{ user.username }}</h4>
<ProseH5>{{ user.email }}</ProseH5> <h4 class="text-xl font-bold">{{ user.email }}</h4>
</div> </div>
</div> </div>
<div class="border-light-red dark:border-dark-red bg-light-redBack dark:bg-dark-redBack text-light-red dark:text-dark-red py-1 px-3 flex items-center justify-between flex-col md:flex-row" <div class="border-light-red dark:border-dark-red bg-light-redBack dark:bg-dark-redBack text-light-red dark:text-dark-red py-1 px-3 flex items-center justify-between flex-col md:flex-row"

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 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 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>
<ProseH4>Inscription</ProseH4> <h4 class="text-xl font-bold">Inscription</h4>
</div> </div>
<form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0"> <form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0">
<TextInput type="text" label="Nom d'utilisateur" name="username" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/> <TextInput type="text" label="Nom d'utilisateur" name="username" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
@@ -20,6 +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> <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> </div>
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/> <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>
<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> <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> <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> </form>
@@ -27,9 +28,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ZodError } from 'zod'; import { ZodError } from 'zod/v4';
import { schema, type Registration } from '~/schemas/registration'; import { schema, type Registration } from '~/schemas/registration';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue';
import { Toaster } from '~~/shared/components';
definePageMeta({ definePageMeta({
layout: 'login', layout: 'login',
@@ -42,7 +44,6 @@ const state = reactive<Registration>({
password: '' password: ''
}); });
const { add: addToast, clear: clearToasts } = useToast();
const confirmPassword = ref(""); const confirmPassword = ref("");
const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128); const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128);
@@ -50,6 +51,7 @@ const checkedLower = computed(() => state.password.toUpperCase() !== state.passw
const checkedUpper = computed(() => state.password.toLowerCase() !== state.password); const checkedUpper = computed(() => state.password.toLowerCase() !== state.password);
const checkedDigit = computed(() => /[0-9]/.test(state.password)); const checkedDigit = computed(() => /[0-9]/.test(state.password));
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e))); const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));
const agreeOnRules = ref<boolean>(false);
const { data: result, status, error, refresh } = await useFetch('/api/auth/register', { const { data: result, status, error, refresh } = await useFetch('/api/auth/register', {
body: state, body: state,
@@ -57,18 +59,20 @@ const { data: result, status, error, refresh } = await useFetch('/api/auth/regis
method: 'POST', method: 'POST',
watch: false, watch: false,
ignoreResponseError: true, ignoreResponseError: true,
}) });
async function submit() async function submit()
{ {
if(state.username === '') if(state.username === '')
return addToast({ content: 'Veuillez saisir un nom d\'utilisateur', timer: true, duration: 10000 }); return Toaster.add({ content: 'Veuillez saisir un nom d\'utilisateur', timer: true, duration: 10000 });
if(state.email === '') if(state.email === '')
return addToast({ content: 'Veuillez saisir une adresse mail', timer: true, duration: 10000 }); return Toaster.add({ content: 'Veuillez saisir une adresse mail', timer: true, duration: 10000 });
if(state.password === "") if(state.password === "")
return addToast({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 }); return Toaster.add({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 });
if(state.password !== confirmPassword.value) if(state.password !== confirmPassword.value)
return addToast({ content: 'Les deux mots de passe saisis ne correspondent pas', timer: true, duration: 10000 }); return Toaster.add({ content: 'Les deux mots de passe saisis ne correspondent pas', timer: true, duration: 10000 });
if(agreeOnRules.value !== true)
return Toaster.add({ content: 'Veuillez accepter des conditions d\'utilisations pour vous inscrire', timer: true, duration: 10000 });
const data = schema.safeParse(state); const data = schema.safeParse(state);
@@ -83,8 +87,8 @@ async function submit()
} }
else if(status.value === 'success' && login.success) else if(status.value === 'success' && login.success)
{ {
clearToasts(); Toaster.clear();
addToast({ duration: 10000, content: 'Vous avez été enregistré. Pensez à valider votre adresse mail.', timer: true, type: 'success' }); Toaster.add({ duration: 10000, content: 'Vous avez été enregistré. Pensez à valider votre adresse mail.', timer: true, type: 'success' });
await navigateTo('/user/profile'); await navigateTo('/user/profile');
} }
} }
@@ -104,12 +108,12 @@ function handleErrors(error: Error | ZodError)
{ {
for(const err of (error as ZodError).issues) for(const err of (error as ZodError).issues)
{ {
return addToast({ content: err.message, timer: true, duration: 10000, type: 'error' }); return Toaster.add({ content: err.message, timer: true, duration: 10000, type: 'error' });
} }
} }
else else
{ {
return addToast({ content: error?.message ?? 'Une erreur est survenue', timer: true, duration: 10000, type: 'error' }); return Toaster.add({ content: error?.message ?? 'Une erreur est survenue', timer: true, duration: 10000, type: 'error' });
} }
} }
</script> </script>

16
app/schemas/project.ts Normal file
View File

@@ -0,0 +1,16 @@
import { z } from "zod";
import { projectFilesTable } from "~/db/schema";
export const Project = z.array(z.object({
id: z.string(),
path: z.string(),
title: z.string(),
type: z.enum(projectFilesTable.type.enumValues),
navigable: z.boolean(),
private: z.boolean(),
order: z.number().finite(),
timestamp: z.string(),
}));
export type ProjectType = z.infer<typeof Project>;
export type ProjectItemType = ProjectType[number];

View File

@@ -1,4 +1,5 @@
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue';
import type { SessionConfig } from 'h3'
import 'vue-router'; import 'vue-router';
declare module 'vue-router' declare module 'vue-router'
@@ -6,15 +7,14 @@ declare module 'vue-router'
interface RouteMeta interface RouteMeta
{ {
requiresAuth?: boolean; requiresAuth?: boolean;
guestsGoesTo?: string;
usersGoesTo?: string; usersGoesTo?: string;
rights?: string[]; rights?: string[];
validState?: boolean; validState?: boolean;
} }
} }
import 'nuxt'; import '@nuxt/schema';
declare module 'nuxt' declare module '@nuxt/schema'
{ {
interface RuntimeConfig interface RuntimeConfig
{ {
@@ -30,9 +30,8 @@ export interface UserRawData {
} }
export interface UserExtendedData { export interface UserExtendedData {
signin: string; signin: Date;
lastTimestamp: string; lastTimestamp: Date;
logCount: number;
} }
export type Permissions = { permissions: string[] }; export type Permissions = { permissions: string[] };

19
app/types/campaign.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
import type { User } from "./auth";
import type { Character, ItemState } from "./character";
import type { Serialize } from 'nitropack';
export type CampaignVariables = {
money: number;
items: ItemState[];
};
export type Campaign = {
id: number;
name: string;
link: string;
status: "PREPARING" | "PLAYING" | "ARCHIVED";
owner: { id: number, username: string };
members: Array<{ member: { id: number, username: string } }>;
characters: Array<Partial<{ character: { id: number, name: string, owner: number } }>>;
public_notes: string;
dm_notes: string;
} & CampaignVariables;

View File

@@ -7,7 +7,7 @@ export type CanvasColor = {
class?: string; class?: string;
} & { } & {
hex?: string; hex?: string;
} };
export interface CanvasNode { export interface CanvasNode {
type: 'group' | 'text'; type: 'group' | 'text';
id: string; id: string;
@@ -17,7 +17,7 @@ export interface CanvasNode {
height: number; height: number;
color?: CanvasColor; color?: CanvasColor;
label?: string; label?: string;
text?: any; text?: string;
}; };
export interface CanvasEdge { export interface CanvasEdge {
id: string; id: string;

300
app/types/character.d.ts vendored Normal file
View File

@@ -0,0 +1,300 @@
import type { MAIN_STATS, ABILITIES, LEVELS, TRAINING_LEVELS, SPELL_TYPES, CATEGORIES, SPELL_ELEMENTS, ALIGNMENTS, RESISTANCES, DAMAGE_TYPES, WEAPON_TYPES, PropertySum, ITEM_BUFFER_KEYS } from "#shared/character";
import type { Localized } from "../types/general";
export type MainStat = typeof MAIN_STATS[number];
export type Ability = typeof ABILITIES[number];
export type Level = typeof LEVELS[number];
export type TrainingLevel = typeof TRAINING_LEVELS[number];
export type SpellType = typeof SPELL_TYPES[number];
export type Category = typeof CATEGORIES[number];
export type SpellElement = typeof SPELL_ELEMENTS[number];
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 FeatureID = string;
export type i18nID = string;
export type RecursiveKeyOf<TObj extends object> = {
[TKey in keyof TObj & (string | number)]:
TObj[TKey] extends any[] ? `${TKey}` :
TObj[TKey] extends object
? `${TKey}` | `${TKey}/${RecursiveKeyOf<TObj[TKey]>}`
: `${TKey}`;
}[keyof TObj & (string | number)];
export type Character = {
id: number;
name: string; //Free text
people?: string; //People ID
level: number;
aspect?: string; //Aspect ID
notes?: { public?: string, private?: string }; //Free text
training: Record<MainStat, Partial<Record<TrainingLevel, number>>>;
leveling: Partial<Record<Level, number>>;
abilities: Partial<Record<Ability, number>>;
variables: CharacterVariables;
choices: Record<FeatureID, number[]>;
owner: number;
username?: string;
visibility: "private" | "public";
campaign?: number;
};
export type CharacterVariables = {
health: number;
mana: number;
exhaustion: number;
sickness: Array<{ id: string, state: number | true }>;
poisons: Array<{ id: string, state: number | true }>;
spells: string[]; //Spell ID
items: ItemState[];
money: number;
};
export enum TreeFlag {
AUTOMATIC = 1 << 0,
REPEATING = 1 << 1,
};
export type TreeLeaf = {
id: FeatureID;
to?: FeatureID | Array<FeatureID> | Record<string, FeatureID>;
flags?: number; //Flags from TreeFlag
};
export type TreeStructure = {
name: string;
starts: FeatureID;
nodes: Record<FeatureID, TreeLeaf>;
};
type CommonState = {
capacity?: number;
powercost?: number;
};
type ArmorState = { loss: number, health?: number, absorb?: { flat?: number, percent?: number } };
type WeaponState = { attack?: number | string, hit?: number };
type WondrousState = { };
type MundaneState = { };
type ItemState = {
id: string;
amount: number;
enchantments?: string[];
charges?: number;
equipped?: boolean;
state?: (ArmorState | WeaponState | WondrousState | MundaneState) & CommonState;
};
export type CharacterConfig = {
peoples: Record<string, RaceConfig>;
training: Record<MainStat, Record<TrainingLevel, FeatureID[]>>;
spells: Record<string, SpellConfig>;
aspects: Record<string, AspectConfig>;
features: Record<FeatureID, Feature>;
enchantments: Record<string, EnchantementConfig>;
items: Record<string, ItemConfig>;
action: Record<string, { id: string, name: string, description: string, cost: number }>;
reaction: Record<string, { id: string, name: string, description: string, cost: number }>;
freeaction: Record<string, { id: string, name: string, description: string }>;
passive: Record<string, { id: string, name: string, description: string }>;
texts: Record<i18nID, Localized>;
trees: Record<string, TreeStructure>;
//Each of these groups extend an existing feature as they all use the same properties
sickness: Record<FeatureID, { name: string, stage: number }>; //TODO
poisons: Record<FeatureID, { name: string, difficulty: number, efficienty: number, solubility: number }>; //TODO
dedications: Record<FeatureID, { name: string, requirement: Array<{ stat: MainStat, amount: number }> }>; //TODO
};
export type EnchantementConfig = {
id: string;
name: string; //TODO -> TextID
description: i18nID;
effect: Array<FeatureEquipment | FeatureValue | FeatureList>;
power: number;
restrictions?: Array<'armor' | 'mundane' | 'wondrous' | 'weapon' | `armor/${ArmorConfig['type']}` | `weapon/${WeaponConfig['type'][number]}`>; // Need to respect *any* of the restriction, not every restrictions.
}
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';
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...)
powercost?: number; //Optionnal
charge?: number //Max amount of charges
enchantments?: string[]; //Enchantment ID
effects?: Array<FeatureValue | FeatureEquipment | FeatureList>;
equippable: boolean;
consummable: boolean;
craft?: { mineral: number, natural: number, processed: number, magical: number };
}
type ArmorConfig = {
category: 'armor';
health: number;
type: 'light' | 'medium' | 'heavy';
absorb: { static: number, percent: number };
};
type WeaponConfig = {
category: 'weapon';
type: Array<WeaponType>;
damage: {
value: string; //Dice formula
type: DamageType;
};
};
type WondrousConfig = {
category: 'wondrous';
};
type MundaneConfig = {
category: 'mundane';
};
export type SpellConfig = {
id: string;
name: string; //TODO -> TextID
rank: 1 | 2 | 3 | 4;
type: SpellType;
cost: number;
speed: "action" | "reaction" | number;
elements: Array<SpellElement>;
description: string; //TODO -> TextID
concentration: boolean;
range: 'personnal' | number;
tags?: string[];
};
export type ArtConfig = {
id: string;
name: string; //TODO -> TextID
rank: 1 | 2 | 3;
type: "arts";
difficulty: number;
description: string; //TODO -> TextID
tags?: string[];
};
export type RaceConfig = {
id: string;
name: string; //TODO -> TextID
description: string; //TODO -> TextID
options: Record<Level, FeatureID[]>;
};
export type AspectConfig = {
id: string;
name: string;
description: string; //TODO -> TextID
stat: MainStat | 'special';
alignment: Alignment;
magic: boolean;
difficulty: number;
physic: { min: number, max: number };
mental: { min: number, max: number };
personality: { min: number, max: number };
options: FeatureItem[];
};
export type FeatureValue = {
id: FeatureID;
category: "value";
operation: "add" | "set" | "min";
property: RecursiveKeyOf<CompiledCharacter> | 'spec' | 'ability' | 'training';
value: number | `modifier/${MainStat}` | false;
}
export type FeatureEquipment = {
id: FeatureID;
category: "value";
operation: "add" | "set" | "min";
property: `item/${RecursiveKeyOf<ArmorState & WeaponState & WondrousState & MundaneState & CommonState>}`;
value: number | `modifier/${MainStat}` | false;
};
export type FeatureList = {
id: FeatureID;
category: "list";
list: "spells" | "sickness" | "action" | "reaction" | "freeaction" | "passive" | "mastery";
action: "add" | "remove";
item: string;
};
export type FeatureTree = {
id: FeatureID;
category: "tree";
tree: string;
option?: string;
};
export type FeatureChoice = {
id: FeatureID;
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 | FeatureTree> }>; //TODO -> TextID
};
export type FeatureItem = FeatureValue | FeatureList | FeatureChoice | FeatureTree;
export type Feature = {
id: FeatureID;
description: string; //TODO -> TextID
effect: FeatureItem[];
};
export type CompiledCharacter = {
id: number;
owner?: number;
username?: string;
name: string;
health: number; //Max
mana: number; //Max
race: string;
spellslots: number; //Max
artslots: number; //Max
spellranks: Record<SpellType | 'arts', 0 | 1 | 2 | 3>;
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;
variables: CharacterVariables,
defense: {
hardcap: number;
static: number;
activeparry: number;
activedodge: number;
passiveparry: number;
passivedodge: number;
};
bonus: {
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
}; //Any special bonus goes here
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
notes: { public: string, private: string };
};

0
app/types/content.d.ts vendored Normal file
View File

View File

@@ -13,4 +13,8 @@ type CanvasPreferences = {
gridSnap: boolean; gridSnap: boolean;
spacing?: number; spacing?: number;
neighborSnap: boolean; neighborSnap: boolean;
}; };
export type Localized = {
fr_FR?: string;
en_US?: string;
}

2021
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,931 +0,0 @@
<script lang="ts">
import { bezier, getBbox, opposite, posFromDir, rotation, type Box, type Direction, type Path, type Position } from '#shared/canvas.util';
import type CanvasNodeEditor from './canvas/CanvasNodeEditor.vue';
import type CanvasEdgeEditor from './canvas/CanvasEdgeEditor.vue';
import { SnapFinder, type SnapHint } from '#shared/physics.util';
import type { CanvasPreferences } from '~/types/general';
export type Element = { type: 'node' | 'edge', id: string };
interface ActionMap {
remove: CanvasNode | CanvasEdge | undefined;
create: CanvasNode | CanvasEdge | undefined;
property: CanvasNode | CanvasEdge;
}
type Action = keyof ActionMap;
interface HistoryEvent<T extends Action = Action>
{
event: T;
actions: HistoryAction<T>[];
}
interface HistoryAction<T extends Action>
{
element: Element;
from: ActionMap[T];
to: ActionMap[T];
}
type NodeEditor = InstanceType<typeof CanvasNodeEditor>;
type EdgeEditor = InstanceType<typeof CanvasEdgeEditor>;
const cancelEvent = (e: Event) => e.preventDefault();
const stopPropagation = (e: Event) => e.stopImmediatePropagation();
function getID(length: number)
{
for (var id = [], i = 0; i < length; i++)
id.push((16 * Math.random() | 0).toString(16));
return id.join("");
}
function center(touches: TouchList): Position
{
const pos = { x: 0, y: 0 };
for(const touch of touches)
{
pos.x += touch.clientX;
pos.y += touch.clientY;
}
pos.x /= touches.length;
pos.y /= touches.length;
return pos;
}
function distance(touches: TouchList): number
{
const [A, B] = touches;
return Math.hypot(B.clientX - A.clientX, B.clientY - A.clientY);
}
</script>
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { clamp } from '#shared/general.util';
import type { CanvasContent, CanvasEdge, CanvasNode } from '~/types/canvas';
const canvas = defineModel<CanvasContent>({ required: true });
const props = defineProps<{
path: string,
}>();
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5), spacing = ref<number | undefined>(32);
const focusing = ref<Element>(), editing = ref<Element>();
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef'), patternRef = useTemplateRef('patternRef'), toolbarRef = useTemplateRef('toolbarRef'), viewportSize = useElementBounding(canvasRef);
const nodes = useTemplateRef<NodeEditor[]>('nodes'), edges = useTemplateRef<EdgeEditor[]>('edges');
const canvasSettings = useCookie<CanvasPreferences>('canvasPreference', { default: () => ({ gridSnap: true, neighborSnap: true, spacing: 32 }) });
const hints = ref<SnapHint[]>([]);
const viewport = computed<Box>(() => {
const width = viewportSize.width.value / zoom.value, height = viewportSize.height.value / zoom.value;
const movementX = viewportSize.width.value - width, movementY = viewportSize.height.value - height;
return { x: -dispX.value + movementX / 2, y: -dispY.value + movementY / 2, w: width, h: height };
});
const updateScaleVar = useDebounceFn(() => {
if(transformRef.value)
{
console.log(zoom.value);
transformRef.value.style.setProperty('--tw-scale', zoom.value.toString());
}
if(canvasRef.value)
{
canvasRef.value.style.setProperty('--zoom-multiplier', (1 / Math.pow(zoom.value, 0.7)).toFixed(3));
}
}, 100);
type DragOrigin = { type: 'edge', id: string, destination: 'from' | 'to', node: string } | { type: 'node', id: string };
const fakeEdge = ref<{ from?: Position, fromSide?: Direction, to?: Position, toSide?: Direction, path?: Path, style?: { stroke: string, fill: string }, hex?: string, drag?: DragOrigin, snapped?: { node: string, side: Direction } }>({});
const focused = computed(() => focusing.value ? focusing.value?.type === 'node' ? nodes.value?.find(e => !!e && e.id === focusing.value!.id) : edges.value?.find(e => !!e && e.id === focusing.value!.id) : undefined), edited = computed(() => editing.value ? editing.value?.type === 'node' ? nodes.value?.find(e => !!e && e.id === editing.value!.id) : edges.value?.find(e => !!e && e.id === editing.value!.id) : undefined);
let snapFinder: SnapFinder;
const history = ref<HistoryEvent[]>([]);
const historyPos = ref(-1);
const historyCursor = computed(() => history.value.length > 0 && historyPos.value > -1 ? history.value[historyPos.value] : undefined);
watch(props, () => {
snapFinder = new SnapFinder(hints, viewport, { gridSize: 512, preferences: canvasSettings.value, threshold: 16, cellSize: 64 })
canvas.value.nodes?.forEach((e) => snapFinder.update(e));
focusing.value = undefined;
editing.value = undefined;
history.value = [];
historyPos.value = -1;
fakeEdge.value = {};
}, { immediate: true });
watch(canvas, () => {
updateToolbarTransform();
}, { immediate: true, deep: true, });
const reset = (_: MouseEvent) => {
zoom.value = minZoom.value;
dispX.value = 0;
dispY.value = 0;
updateTransform();
}
function addAction<T extends Action = Action>(event: T, actions: HistoryAction<T>[])
{
historyPos.value++;
history.value.splice(historyPos.value, history.value.length - historyPos.value);
history.value[historyPos.value] = { event, actions };
}
onMounted(() => {
let lastX = 0, lastY = 0, lastDistance = 0;
const dragMove = (e: MouseEvent) => {
dispX.value = dispX.value - (lastX - e.clientX) / zoom.value;
dispY.value = dispY.value - (lastY - e.clientY) / zoom.value;
lastX = e.clientX;
lastY = e.clientY;
updateTransform();
};
const dragEnd = (e: MouseEvent) => {
window.removeEventListener('mouseup', dragEnd);
window.removeEventListener('mousemove', dragMove);
};
canvasRef.value?.addEventListener('mouseenter', () => {
window.addEventListener('wheel', cancelEvent, { passive: false });
document.addEventListener('gesturestart', cancelEvent);
document.addEventListener('gesturechange', cancelEvent);
canvasRef.value?.addEventListener('mouseleave', () => {
window.removeEventListener('wheel', cancelEvent);
document.removeEventListener('gesturestart', cancelEvent);
document.removeEventListener('gesturechange', cancelEvent);
});
})
canvasRef.value?.addEventListener('mousedown', (e) => {
if(e.button === 1)
{
lastX = e.clientX;
lastY = e.clientY;
window.addEventListener('mouseup', dragEnd, { passive: true });
window.addEventListener('mousemove', dragMove, { passive: true });
}
}, { passive: true });
canvasRef.value?.addEventListener('wheel', (e) => {
if((zoom.value >= 3 && e.deltaY < 0) || (zoom.value <= minZoom.value && e.deltaY > 0))
return;
const diff = Math.exp(e.deltaY * -0.001);
const centerX = (viewportSize.x.value + viewportSize.width.value / 2), centerY = (viewportSize.y.value + viewportSize.height.value / 2);
const mousex = centerX - e.clientX, mousey = centerY - e.clientY;
dispX.value = dispX.value - (mousex / (diff * zoom.value) - mousex / zoom.value);
dispY.value = dispY.value - (mousey / (diff * zoom.value) - mousey / zoom.value);
zoom.value = clamp(zoom.value * diff, minZoom.value, 3);
spacing.value = canvasSettings.value.gridSnap ? canvasSettings.value.spacing ?? 32 : undefined;
updateTransform();
}, { passive: true });
canvasRef.value?.addEventListener('touchstart', (e) => {
({ x: lastX, y: lastY } = center(e.touches));
if(e.touches.length > 1)
{
lastDistance = distance(e.touches);
}
canvasRef.value?.addEventListener('touchend', touchend, { passive: true });
canvasRef.value?.addEventListener('touchcancel', touchcancel, { passive: true });
canvasRef.value?.addEventListener('touchmove', touchmove, { passive: true });
}, { passive: true });
const touchend = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: lastX, y: lastY } = center(e.touches));
}
canvasRef.value?.removeEventListener('touchend', touchend);
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
canvasRef.value?.removeEventListener('touchmove', touchmove);
};
const touchcancel = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: lastX, y: lastY } = center(e.touches));
}
canvasRef.value?.removeEventListener('touchend', touchend);
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
canvasRef.value?.removeEventListener('touchmove', touchmove);
};
const touchmove = (e: TouchEvent) => {
const pos = center(e.touches);
dispX.value = dispX.value - (lastX - pos.x) / zoom.value;
dispY.value = dispY.value - (lastY - pos.y) / zoom.value;
lastX = pos.x;
lastY = pos.y;
if(e.touches.length === 2)
{
const dist = distance(e.touches);
const diff = dist / lastDistance;
zoom.value = clamp(zoom.value * diff, minZoom.value, 3);
}
updateTransform();
};
updateTransform();
});
function updateTransform()
{
if(transformRef.value)
{
transformRef.value.style.transform = `scale3d(${zoom.value}, ${zoom.value}, 1) translate3d(${dispX.value}px, ${dispY.value}px, 0)`;
updateScaleVar();
}
if(patternRef.value && canvasSettings.value.gridSnap)
{
patternRef.value.parentElement?.classList.remove('hidden');
patternRef.value.setAttribute("x", (viewportSize.width.value / 2 + dispX.value % spacing.value! * zoom.value).toFixed(3));
patternRef.value.setAttribute("y", (viewportSize.height.value / 2 + dispY.value % spacing.value! * zoom.value).toFixed(3));
patternRef.value.setAttribute("width", (zoom.value * spacing.value!).toFixed(3));
patternRef.value.setAttribute("height", (zoom.value * spacing.value!).toFixed(3));
patternRef.value.children[0].setAttribute('cx', (zoom.value).toFixed(3));
patternRef.value.children[0].setAttribute('cy', (zoom.value).toFixed(3));
patternRef.value.children[0].setAttribute('r', (zoom.value).toFixed(3));
}
else if(patternRef.value && !canvasSettings.value.gridSnap)
{
patternRef.value.parentElement?.classList.remove('hidden');
}
}
function updateToolbarTransform()
{
const offsetY = -12;
if(toolbarRef.value)
{
if(!focusing.value)
{
toolbarRef.value.style.transform = '';
}
else if(focusing.value.type === 'node')
{
const node = canvas.value.nodes!.find(e => e.id === focusing.value!.id)!;
toolbarRef.value.style.transform = `translate(${node.x}px, ${node.y}px) translateY(-100%) translateY(${offsetY}px) translateX(-50%) translateX(${node.width / 2}px) scale(calc(1 / var(--tw-scale)))`;
}
else
{
const path = edges.value!.find(e => e.id === focusing.value!.id)!.path;
const x = path.from.x + (path.to.x - path.from.x) / 2, y = path.from.y;
toolbarRef.value.style.transform = `translate(${x}px, ${y}px) translateY(-100%) translateY(${offsetY}px) translateX(-50%) scale(calc(1 / var(--tw-scale)))`;
}
}
}
function moveNode(ids: string[], deltax: number, deltay: number)
{
if(ids.length === 0)
return;
const actions: HistoryAction<'property'>[] = [];
for(const id of ids)
{
const node = canvas.value.nodes!.find(e => e.id === id)!;
snapFinder.update(node);
actions.push({ element: { type: 'node', id }, from: { ...node, x: node.x - deltax, y: node.y - deltay }, to: { ...node } });
}
addAction('property', actions);
}
function resizeNode(ids: string[], deltax: number, deltay: number, deltaw: number, deltah: number)
{
if(ids.length === 0)
return;
const actions: HistoryAction<'property'>[] = [];
for(const id of ids)
{
const node = canvas.value.nodes!.find(e => e.id === id)!;
snapFinder.update(node);
actions.push({ element: { type: 'node', id }, from: { ...node, x: node.x - deltax, y: node.y - deltay, width: node.width - deltaw, height: node.height - deltah }, to: { ...node } });
}
addAction('property', actions);
}
function select(element: Element)
{
if(focusing.value && (focusing.value.id !== element.id || focusing.value.type !== element.type))
{
unselect();
}
focusing.value = element;
focused.value?.dom?.addEventListener('click', stopPropagation, { passive: true });
canvasRef.value?.addEventListener('click', unselect, { once: true });
updateToolbarTransform();
}
function edit(element: Element)
{
editing.value = element;
focused.value?.dom?.addEventListener('wheel', stopPropagation, { passive: true });
focused.value?.dom?.addEventListener('dblclick', stopPropagation, { passive: true });
canvasRef.value?.addEventListener('click', unselect, { once: true });
}
function createNode(e: MouseEvent)
{
let box = canvasRef.value?.getBoundingClientRect()!;
const width = 250, height = 100;
const x = (e.layerX / zoom.value) - dispX.value - (width / 2);
const y = (e.layerY / zoom.value) - dispY.value - (height / 2);
const node: CanvasNode = { id: getID(16), x, y, width, height, type: 'text' };
if(!canvas.value.nodes)
canvas.value.nodes = [node];
else
canvas.value.nodes.push(node);
snapFinder.add(node);
addAction('create', [{ element: { type: 'node', id: node.id }, from: undefined, to: node }]);
}
function remove(elements: Element[])
{
if(elements.length === 0)
return;
const actions: HistoryAction<'remove'>[] = [];
focusing.value = undefined;
editing.value = undefined;
const c = canvas.value;
for(const element of elements)
{
if(element.type === 'node')
{
const edges = c.edges?.map((e, i) => ({ id: e.id, from: e.fromNode, to: e.toNode, index: i }))?.filter(e => e.from === element.id || e.to === element.id) ?? [];
for(let i = edges.length - 1; i >= 0; i--)
{
actions.push({ element: { type: 'edge', id: edges[i].id }, from: c.edges!.splice(edges[i].index, 1)[0], to: undefined });
}
const index = c.nodes!.findIndex(e => e.id === element.id);
const node = c.nodes!.splice(index, 1)[0];
snapFinder.remove(node);
actions.push({ element: { type: 'node', id: element.id }, from: node, to: undefined });
}
else if(element.type === 'edge' && !actions.find(e => e.element.type === 'edge' && e.element.id === element.id))
{
const index = c.edges!.findIndex(e => e.id === element.id);
actions.push({ element: { type: 'edge', id: element.id }, from: c.edges!.splice(index, 1)[0], to: undefined });
}
}
canvas.value = c;
addAction('remove', actions);
}
function dragEdgeTo(e: MouseEvent): void
{
(fakeEdge.value.to as Position).x += e.movementX / zoom.value;
(fakeEdge.value.to as Position).y += e.movementY / zoom.value;
const result = snapFinder.findEdgeSnapPosition(fakeEdge.value.drag!.id, fakeEdge.value.to!.x, fakeEdge.value.to!.y);
fakeEdge.value.snapped = result ? { node: result.node, side: result.direction } : undefined;
fakeEdge.value.path = bezier((fakeEdge.value.from as Position), fakeEdge.value.fromSide!, result ?? (fakeEdge.value.to as Position), result?.direction ?? fakeEdge.value.toSide!);
}
function dragEndEdgeTo(e: MouseEvent): void
{
window.removeEventListener('mousemove', dragEdgeTo);
window.removeEventListener('mouseup', dragEndEdgeTo);
if(fakeEdge.value.snapped)
{
const node = canvas.value.nodes!.find(e => e.id === fakeEdge.value.drag!.id)!;
const edge: CanvasEdge = { fromNode: fakeEdge.value.drag!.id, fromSide: fakeEdge.value.fromSide!, toNode: fakeEdge.value.snapped.node, toSide: fakeEdge.value.snapped.side, id: getID(16), color: node.color };
canvas.value.edges?.push(edge);
addAction('create', [{ from: undefined, to: edge, element: { id: edge.id, type: 'edge' } }]);
}
fakeEdge.value = {};
}
function dragStartEdgeTo(id: string, e: MouseEvent, direction: Direction): void
{
const node = canvas.value.nodes!.find(e => e.id === id)!;
fakeEdgeFromNode(node, direction);
window.addEventListener('mousemove', dragEdgeTo, { passive: true });
window.addEventListener('mouseup', dragEndEdgeTo, { passive: true });
}
function dragEdgeSide(e: MouseEvent): void
{
if(fakeEdge.value.drag?.type === 'node')
return;
const destination = fakeEdge.value.drag!.destination;
const pos = fakeEdge.value[destination]!;
pos.x += e.movementX / zoom.value;
pos.y += e.movementY / zoom.value;
const result = snapFinder.findEdgeSnapPosition(fakeEdge.value.drag!.node, pos.x, pos.y);
fakeEdge.value.snapped = result ? { node: result.node, side: result.direction } : undefined;
fakeEdge.value.path = bezier(destination === 'from' ? (result ?? pos) : fakeEdge.value.from!, destination === 'from' ? result?.direction ?? fakeEdge.value.fromSide! : fakeEdge.value.fromSide!, destination === 'to' ? (result ?? pos) : fakeEdge.value.to!, destination === 'to' ? result?.direction ?? fakeEdge.value.toSide! : fakeEdge.value.toSide!);
}
function dragEndEdgeSide(e: MouseEvent): void
{
if(fakeEdge.value.drag?.type === 'node')
return;
window.removeEventListener('mousemove', dragEdgeSide);
window.removeEventListener('mouseup', dragEndEdgeSide);
if(fakeEdge.value.snapped)
{
const edge = canvas.value.edges!.find(e => e.id === fakeEdge.value.drag?.id)!
const old = { ... edge };
const destination = fakeEdge.value.drag!.destination;
edge.fromNode = destination === 'to' ? fakeEdge.value.drag!.node : fakeEdge.value.snapped.node;
edge.fromSide = destination === 'to' ? fakeEdge.value.fromSide! : fakeEdge.value.snapped.side;
edge.toNode = destination === 'from' ? fakeEdge.value.drag!.node : fakeEdge.value.snapped.node;
edge.toSide = destination === 'from' ? fakeEdge.value.toSide! : fakeEdge.value.snapped.side;
addAction('property', [{ from: old, to: edge, element: { id: edge.id, type: 'edge' } }]);
}
fakeEdge.value = {};
}
function dragStartEdgeSide(id: string, e: MouseEvent, direction: 'from' | 'to'): void
{
const edge = canvas.value.edges!.find(e => e.id === id)!;
fakeEdgeFromEdge(edge, direction);
window.addEventListener('mousemove', dragEdgeSide, { passive: true });
window.addEventListener('mouseup', dragEndEdgeSide, { passive: true });
}
function fakeEdgeFromEdge(edge: CanvasEdge, direction: 'from' | 'to'): void
{
fakeEdge.value.drag = { type: 'edge', id: edge.id, destination: direction, node: direction === 'to' ? edge.fromNode : edge.toNode };
const destinationNode = direction === 'from' ? canvas.value.nodes!.find(e => e.id === edge.fromNode)! : canvas.value.nodes!.find(e => e.id === edge.toNode)!;
const otherNode = direction === 'from' ? canvas.value.nodes!.find(e => e.id === edge.toNode)! : canvas.value.nodes!.find(e => e.id === edge.fromNode)!;
const destinationPos = posFromDir(getBbox(destinationNode), direction === 'from' ? edge.fromSide : edge.toSide);
const otherPos = posFromDir(getBbox(otherNode), direction === 'from' ? edge.toSide : edge.fromSide);
fakeEdge.value.from = direction === 'from' ? destinationPos : otherPos;
fakeEdge.value.fromSide = edge.fromSide;
fakeEdge.value.to = direction === 'to' ? destinationPos : otherPos;
fakeEdge.value.toSide = edge.toSide;
fakeEdge.value.path = bezier(destinationPos, edge.fromSide, otherPos, edge.toSide);
fakeEdge.value.hex = edge.color?.hex;
fakeEdge.value.style = edge?.color ? edge.color?.class ?
{ fill: `fill-light-${edge.color?.class} dark:fill-dark-${edge.color?.class}`, stroke: `stroke-light-${edge.color?.class} dark:stroke-dark-${edge.color?.class}` } :
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]` } :
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40` };
}
function fakeEdgeFromNode(node: CanvasNode, direction: Direction): void
{
const pos = posFromDir(getBbox(node), direction);
fakeEdge.value.drag = { type: 'node', id: node.id };
fakeEdge.value.from = { ... pos };
fakeEdge.value.fromSide = direction;
fakeEdge.value.to = { ... pos };
fakeEdge.value.toSide = opposite[direction];
fakeEdge.value.path = bezier(pos, fakeEdge.value.fromSide!, pos, fakeEdge.value.toSide!);
fakeEdge.value.hex = node.color?.hex;
fakeEdge.value.style = node?.color ? node.color?.class ?
{ fill: `fill-light-${node.color?.class} dark:fill-dark-${node.color?.class}`, stroke: `stroke-light-${node.color?.class} dark:stroke-dark-${node.color?.class}` } :
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]` } :
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40` };
}
function editNodeProperty<T extends keyof CanvasNode>(ids: string[], property: T, value: CanvasNode[T])
{
if(ids.length === 0)
return;
const actions: HistoryAction<'property'>[] = [];
for(const id of ids)
{
const copy = JSON.parse(JSON.stringify(canvas.value.nodes!.find(e => e.id === id)!)) as CanvasNode;
canvas.value.nodes!.find(e => e.id === id)![property] = value;
actions.push({ element: { type: 'node', id }, from: copy, to: canvas.value.nodes!.find(e => e.id === id)! });
}
addAction('property', actions);
}
function editEdgeProperty<T extends keyof CanvasEdge>(ids: string[], property: T, value: CanvasEdge[T])
{
if(ids.length === 0)
return;
const actions: HistoryAction<'property'>[] = [];
for(const id of ids)
{
const copy = JSON.parse(JSON.stringify(canvas.value.edges!.find(e => e.id === id)!)) as CanvasEdge;
canvas.value.edges!.find(e => e.id === id)![property] = value;
actions.push({ element: { type: 'edge', id }, from: copy, to: canvas.value.edges!.find(e => e.id === id)! });
}
addAction('property', actions);
}
const unselect = () => {
if(focusing.value !== undefined)
{
focused.value?.dom?.removeEventListener('click', stopPropagation);
focused.value?.unselect();
updateToolbarTransform();
}
focusing.value = undefined;
if(editing.value !== undefined)
{
edited.value?.dom?.removeEventListener('wheel', stopPropagation);
edited.value?.dom?.removeEventListener('dblclick', stopPropagation);
edited.value?.dom?.removeEventListener('click', stopPropagation);
edited.value?.unselect();
}
editing.value = undefined;
};
const undo = () => {
if(!historyCursor.value)
return;
for(const action of historyCursor.value.actions)
{
if(action.element.type === 'node')
{
switch(historyCursor.value.event)
{
case 'create':
{
const a = action as HistoryAction<'create'>;
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
snapFinder.remove(canvas.value.nodes!.splice(index, 1)[0]);
break;
}
case 'remove':
{
const a = action as HistoryAction<'remove'>;
canvas.value.nodes!.push(a.from as CanvasNode);
snapFinder.add(a.from as CanvasNode);
break;
}
case 'property':
{
const a = action as HistoryAction<'property'>;
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
canvas.value.nodes![index] = a.from as CanvasNode;
snapFinder.update(a.from as CanvasNode);
break;
}
}
}
else if(action.element.type === 'edge')
{
switch(historyCursor.value.event)
{
case 'create':
{
const a = action as HistoryAction<'create'>;
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
canvas.value.edges!.splice(index, 1);
break;
}
case 'remove':
{
const a = action as HistoryAction<'remove'>;
canvas.value.edges!.push(a.from! as CanvasEdge);
break;
}
case 'property':
{
const a = action as HistoryAction<'property'>;
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
canvas.value.edges![index] = a.from as CanvasEdge;
break;
}
}
}
}
historyPos.value--;
};
const redo = () => {
if(!history.value || history.value.length - 1 <= historyPos.value)
return;
historyPos.value++;
if(!historyCursor.value)
{
historyPos.value--;
return;
}
for(const action of historyCursor.value.actions)
{
if(action.element.type === 'node')
{
switch(historyCursor.value.event)
{
case 'create':
{
const a = action as HistoryAction<'create'>;
canvas.value.nodes!.push(a.to as CanvasNode);
snapFinder.add(a.to as CanvasNode);
break;
}
case 'remove':
{
const a = action as HistoryAction<'remove'>;
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
snapFinder.remove(canvas.value.nodes!.splice(index, 1)[0]);
break;
}
case 'property':
{
const a = action as HistoryAction<'property'>;
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
canvas.value.nodes![index] = a.to as CanvasNode;
snapFinder.update(a.to as CanvasNode);
break;
}
}
}
else if(action.element.type === 'edge')
{
switch(historyCursor.value.event)
{
case 'create':
{
const a = action as HistoryAction<'create'>;
canvas.value.edges!.push(a.to as CanvasEdge);
break;
}
case 'remove':
{
const a = action as HistoryAction<'remove'>;
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
canvas.value.edges!.splice(index, 1);
break;
}
case 'property':
{
const a = action as HistoryAction<'property'>;
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
canvas.value.edges![index] = a.to as CanvasEdge;
break;
}
}
}
}
};
useShortcuts({
meta_z: undo,
meta_y: redo,
Delete: () => { if(focusing.value !== undefined) { remove([focusing.value]) } }
});
</script>
<template>
<div ref="canvasRef" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none" @dblclick.left="createNode">
<div class="flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4" @click="stopPropagation" @dblclick="stopPropagation">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Zoom avant" side="right">
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:plus" />
</div>
</Tooltip>
<Tooltip message="Reset" side="right">
<div @click="zoom = 1; updateTransform();" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:reload" />
</div>
</Tooltip>
<Tooltip message="Tout contenir" side="right">
<div @click="reset" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:corners" />
</div>
</Tooltip>
<Tooltip message="Zoom arrière" side="right">
<div @click="zoom = clamp(zoom / 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:minus" />
</div>
</Tooltip>
</div>
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Annuler (Ctrl+Z)" side="right">
<div @click="undo" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer" :class="{ 'text-light-50 dark:text-dark-50 !cursor-default hover:bg-transparent dark:hover:bg-transparent': historyPos === -1 }">
<Icon icon="ph:arrow-bend-up-left" />
</div>
</Tooltip>
<Tooltip message="Retablir (Ctrl+Y)" side="right">
<div @click="redo" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer" :class="{ 'text-light-50 dark:text-dark-50 !cursor-default hover:bg-transparent dark:hover:bg-transparent': historyPos === history.length - 1 }">
<Icon icon="ph:arrow-bend-up-right" />
</div>
</Tooltip>
</div>
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Préférences" side="right">
<Dialog title="Préférences" iconClose>
<template #trigger>
<div class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:gear" />
</div>
</template>
<template #default>
<Switch v-model="canvasSettings.neighborSnap" label="S'accrocher aux voisins" @update:model-value="snapFinder.config.preferences = canvasSettings" />
<Switch v-model="canvasSettings.gridSnap" label="S'accrocher à la grille" @update:model-value="(v) => { canvasSettings.spacing = v ? 32 : undefined; snapFinder.config.preferences = canvasSettings }" />
<NumberPicker v-model="canvasSettings.spacing" label="Taille de la grille" :disabled="!canvasSettings.gridSnap" @update:model-value="(v) => { spacing = v; updateTransform(); snapFinder.config.preferences = canvasSettings}" />
</template>
</Dialog>
</Tooltip>
<Tooltip message="Aide" side="right">
<Dialog title="Aide" iconClose>
<template #trigger>
<div class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:question-mark-circled" />
</div>
</template>
<template #default>
<div class="flex flex-row justify-between px-4">
<div class="flex flex-col gap-2">
<ProseH4>Ordinateur</ProseH4>
<div class="flex items-center"><Icon icon="ph:mouse-left-click-fill" class="w-6 h-6"/>: Selectionner</div>
<div class="flex items-center"><Icon icon="ph:mouse-left-click-fill" class="w-6 h-6"/><Icon icon="ph:mouse-left-click-fill" class="w-6 h-6"/>: Modifier</div>
<div class="flex items-center"><Icon icon="ph:mouse-middle-click-fill" class="w-6 h-6"/>: Déplacer</div>
<div class="flex items-center"><Icon icon="ph:mouse-right-click-fill" class="w-6 h-6"/>: Menu</div>
</div>
<div class="flex flex-col gap-2">
<ProseH4>Mobile</ProseH4>
<div class="flex items-center"><Icon icon="ph:hand-tap" class="w-6 h-6"/>: Selectionner</div>
<div class="flex items-center"><Icon icon="ph:hand-tap" class="w-6 h-6"/><Icon icon="ph:hand-tap" class="w-6 h-6"/>: Modifier</div>
<div class="flex items-center"><Icon icon="mdi:gesture-pinch" class="w-6 h-6"/>: Zoomer</div>
<div class="flex items-center"><Icon icon="ph:hand-tap" class="w-6 h-6"/> maintenu: Menu</div>
</div>
</div>
</template>
</Dialog>
</Tooltip>
</div>
</div>
<svg class="absolute top-0 left-0 w-full h-full pointer-events-none">
<pattern ref="patternRef" id="canvasPattern" patternUnits="userSpaceOnUse">
<circle cx="0.75" cy="0.75" r="0.75" class="fill-light-35 dark:fill-dark-35"></circle>
</pattern>
<rect x="0" y="0" width="100%" height="100%" fill="url(#canvasPattern)"></rect>
</svg>
<div ref="transformRef" :style="{
'transform-origin': 'center center',
}" class="h-full">
<div class="absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none">
<div class="absolute z-20 destination-bottom" ref="toolbarRef">
<template v-if="focusing">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row" v-if="focusing.type === 'node'">
<PopoverRoot>
<PopoverTrigger asChild>
<div @click="stopPropagation">
<Tooltip message="Couleur" side="top">
<div class="w-10 h-10 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="ph:palette" class="w-6 h-6" />
</div>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverPortal disabled>
<PopoverContent align="center" side="top" class="bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 m-2">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row *:cursor-pointer">
<div @click="editNodeProperty([focusing.id], 'color', undefined)" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-40 dark:bg-dark-40 w-4 h-4 block"></span>
</div>
<div @click="editNodeProperty([focusing.id], 'color', { class: 'red' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-red dark:bg-dark-red w-4 h-4 block"></span>
</div>
<div @click="editNodeProperty([focusing.id], 'color', { class: 'orange' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-orange dark:bg-dark-orange w-4 h-4 block"></span>
</div>
<div @click="editNodeProperty([focusing.id], 'color', { class: 'yellow' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-yellow dark:bg-dark-yellow w-4 h-4 block"></span>
</div>
<div @click="editNodeProperty([focusing.id], 'color', { class: 'green' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-green dark:bg-dark-green w-4 h-4 block"></span>
</div>
<div @click="editNodeProperty([focusing.id], 'color', { class: 'cyan' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-cyan dark:bg-dark-cyan w-4 h-4 block"></span>
</div>
<div @click="editNodeProperty([focusing.id], 'color', { class: 'purple' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-purple dark:bg-dark-purple w-4 h-4 block"></span>
</div>
<label>
<div @click="stopPropagation" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span style="background: conic-gradient(red, yellow, green, blue, purple, red)" class="w-4 h-4 block relative"></span><input @change="(e: Event) => editNodeProperty([focusing!.id], 'color', { hex: (e.target as HTMLInputElement).value })" type="color" class="appearance-none w-0 h-0 absolute" />
</div>
</label>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<Tooltip message="Supprimer" side="top">
<div @click="remove([focusing])" class="w-10 h-10 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:trash" class="text-light-red dark:text-dark-red w-6 h-6" />
</div>
</Tooltip>
</div>
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row" v-else>
<PopoverRoot>
<PopoverTrigger asChild>
<div @click="stopPropagation">
<Tooltip message="Couleur" side="top">
<div class="w-10 h-10 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="ph:palette" class="w-6 h-6" />
</div>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverPortal disabled>
<PopoverContent align="center" side="top" class="bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 m-2">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row *:cursor-pointer">
<div @click="editEdgeProperty([focusing.id], 'color', undefined)" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-40 dark:bg-dark-40 w-4 h-4 block"></span>
</div>
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'red' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-red dark:bg-dark-red w-4 h-4 block"></span>
</div>
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'orange' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-orange dark:bg-dark-orange w-4 h-4 block"></span>
</div>
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'yellow' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-yellow dark:bg-dark-yellow w-4 h-4 block"></span>
</div>
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'green' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-green dark:bg-dark-green w-4 h-4 block"></span>
</div>
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'cyan' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-cyan dark:bg-dark-cyan w-4 h-4 block"></span>
</div>
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'purple' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span class="bg-light-purple dark:bg-dark-purple w-4 h-4 block"></span>
</div>
<label>
<div @click="stopPropagation" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
<span style="background: conic-gradient(red, yellow, green, blue, purple, red)" class="w-4 h-4 block relative"></span><input @change="(e: Event) => editEdgeProperty([focusing!.id], 'color', { hex: (e.target as HTMLInputElement).value })" type="color" class="appearance-none w-0 h-0 absolute" />
</div>
</label>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<Tooltip message="Supprimer" side="top">
<div @click="remove([focusing])" class="w-10 h-10 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:trash" class="text-light-red dark:text-dark-red w-6 h-6" />
</div>
</Tooltip>
</div>
</template>
</div>
<div>
<CanvasNodeEditor v-for="node of canvas.nodes" :key="node.id" ref="nodes" :node="node" :zoom="zoom"
@select="select" @edit="edit" @move="(i, x, y) => moveNode([i], x, y)" @resize="(i, x, y, w, h) => resizeNode([i], x, y, w, h)" @input="(id, text) => editNodeProperty([id], node.type === 'group' ? 'label' : 'text', text)" :snap="snapFinder.findNodeSnapPosition.bind(snapFinder)" @edge="dragStartEdgeTo" />
</div>
<div>
<CanvasEdgeEditor v-for="edge of canvas.edges" :key="edge.id" ref="edges" :edge="edge" :nodes="canvas.nodes!" @select="select" @edit="edit" @input="(id, text) => editEdgeProperty([id], 'label', text)" @drag="dragStartEdgeSide" />
<div v-if="fakeEdge.path" class="absolute overflow-visible">
<svg class="absolute top-0 overflow-visible h-px w-px">
<g :style="{'--canvas-color': fakeEdge.hex}" class="z-0">
<g :style="`transform: translate(${fakeEdge.path!.to.x}px, ${fakeEdge.path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[fakeEdge.path!.side]}deg);`">
<polygon :class="fakeEdge.style?.fill" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
</g>
<path :style="`stroke-width: calc(3px * var(--zoom-multiplier));`" style="stroke-linecap: butt;" :class="fakeEdge.style?.stroke" class="fill-none stroke-[4px]" :d="fakeEdge.path.path"></path>
</g>
</svg>
</div>
</div>
<svg class="absolute overflow-visible top-0 h-px w-px fill-accent-purple stroke-accent-purple stroke-1 z-50">
<g v-for="hint of hints">
<circle :cx="hint.start.x" :cy="hint.start.y" r="3" />
<circle v-if="hint.end" :cx="hint.end.x" :cy="hint.end.y" r="3" />
<line v-if="hint.end" :x1="hint.start.x" :x2="hint.end.x" :y1="hint.start.y" :y2="hint.end.y"/>
</g>
</svg>
</div>
</div>
</div>
</template>

View File

@@ -1,235 +0,0 @@
<script lang="ts">
import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, WidgetType, type DecorationSet } from '@codemirror/view';
import { Annotation, EditorState, RangeValue, SelectionRange, type Range } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, defaultHighlightStyle, foldKeymap, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
import { search, searchKeymap } from '@codemirror/search';
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { IterMode, Tree } from '@lezer/common';
import { tags } from '@lezer/highlight';
const External = Annotation.define<boolean>();
const Hidden = Decoration.mark({ class: 'hidden' });
const Bullet = Decoration.mark({ class: '*:hidden before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4' });
const Blockquote = Decoration.line({ class: '*:hidden before:block !ps-4 relative before:absolute before:top-0 before:bottom-0 before:left-0 before:w-1 before:bg-none before:bg-light-30 dark:before:bg-dark-30' });
const TagTag = tags.special(tags.content);
const intersects = (a: {
from: number;
to: number;
}, b: {
from: number;
to: number;
}) => !(a.to < b.from || b.to < a.from);
const highlight = HighlightStyle.define([
{ tag: tags.heading1, class: 'text-5xl pt-4 pb-2 after:hidden' },
{ tag: tags.heading2, class: 'text-4xl pt-4 pb-2 ps-1 leading-loose after:hidden' },
{ tag: tags.heading3, class: 'text-2xl font-bold pt-1 after:hidden' },
{ tag: tags.heading4, class: 'text-xl font-semibold pt-1 after:hidden variant-cap' },
{ tag: tags.meta, color: "#404740" },
{ tag: tags.link, textDecoration: "underline" },
{ tag: tags.heading, textDecoration: "underline", fontWeight: "bold" },
{ tag: tags.emphasis, fontStyle: "italic" },
{ tag: tags.strong, fontWeight: "bold" },
{ tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: tags.keyword, color: "#708" },
{ tag: TagTag, class: 'cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30' }
]);
class Decorator
{
static hiddenNodes: string[] = [
'HardBreak',
'LinkMark',
'EmphasisMark',
'CodeMark',
'CodeInfo',
'URL',
]
decorations: DecorationSet;
constructor(view: EditorView)
{
this.decorations = Decoration.set(this.iterate(syntaxTree(view.state), view.visibleRanges, []), true);
}
update(update: ViewUpdate)
{
if(!update.docChanged && !update.viewportChanged && !update.selectionSet)
return;
this.decorations = this.decorations.update({
filter: (f, t, v) => false,
add: this.iterate(syntaxTree(update.state), update.view.visibleRanges, update.state.selection.ranges),
sort: true,
});
}
iterate(tree: Tree, visible: readonly {
from: number;
to: number;
}[], selection: readonly SelectionRange[]): Range<Decoration>[]
{
const decorations: Range<Decoration>[] = [];
for (let { from, to } of visible) {
tree.iterate({
from, to, mode: IterMode.IgnoreMounts,
enter: node => {
if(node.node.parent && selection.some(e => intersects(e, node.node.parent!)))
return true;
else if(node.name === 'HeaderMark')
decorations.push(Hidden.range(node.from, node.to + 1));
else if(Decorator.hiddenNodes.includes(node.name))
decorations.push(Hidden.range(node.from, node.to));
else if(node.matchContext(['BulletList', 'ListItem']) && node.name === 'ListMark')
decorations.push(Bullet.range(node.from, node.to + 1));
else if(node.matchContext(['Blockquote']))
decorations.push(Blockquote.range(node.from, node.from));
return true;
},
});
}
return decorations;
}
}
</script>
<script setup lang="ts">
const { autofocus = false } = defineProps<{
placeholder?: string
autofocus?: boolean
}>();
const model = defineModel<string>();
const editor = useTemplateRef('editor');
const view = ref<EditorView>();
onMounted(() => {
if(editor.value)
{
view.value = new EditorView({
doc: model.value,
parent: editor.value,
extensions: [
markdown({
base: markdownLanguage,
extensions: {
defineNodes: [
{ name: "Tag", style: TagTag },
{ name: "TagMark", style: tags.processingInstruction }
],
parseInline: [{
name: "Tag",
parse(cx, next, pos) {
if (next != 35 || cx.char(pos + 1) == 35) return -1;
let elts = [cx.elt("TagMark", pos, pos + 1)];
for (let i = pos + 1; i < cx.end; i++) {
let next = cx.char(i);
if (next == 35)
return cx.addElement(cx.elt("Tag", pos, i + 1, elts.concat(cx.elt("TagMark", i, i + 1))));
if (next == 92)
elts.push(cx.elt("Escape", i, i++ + 2));
if (next == 32 || next == 9 || next == 10 || next == 13) break;
}
return -1
}
}],
}
}),
history(),
search(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(highlight),
bracketMatching(),
closeBrackets(),
crosshairCursor(),
EditorView.lineWrapping,
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap
]),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
{
model.value = viewUpdate.state.doc.toString();
}
}),
EditorView.contentAttributes.of({spellcheck: "true"}),
ViewPlugin.fromClass(Decorator, {
decorations: e => e.decorations,
})
]
});
if(autofocus)
{
view.value.focus();
}
}
});
onBeforeUnmount(() => {
if (view.value)
{
view.value?.destroy();
view.value = undefined;
}
});
watchEffect(() => {
if (model.value === void 0) {
return;
}
const currentValue = view.value ? view.value.state.doc.toString() : "";
if (view.value && model.value !== currentValue) {
view.value.dispatch({
changes: { from: 0, to: currentValue.length, insert: model.value || "" },
annotations: [External.of(true)],
});
}
});
defineExpose({ focus: () => editor.value?.focus() });
</script>
<template>
<div ref="editor" class="flex flex-1 w-full justify-stretch items-stretch py-2 px-1.5 font-sans text-base"></div>
</template>
<style>
.variant-cap
{
font-variant: small-caps;
}
.cm-editor
{
@apply bg-transparent;
@apply flex-1 h-full;
@apply font-sans;
@apply text-light-100 dark:text-dark-100;
}
.cm-editor .cm-content
{
@apply caret-light-100 dark:caret-dark-100;
}
.cm-line
{
@apply text-base;
@apply font-sans;
}
</style>

View File

@@ -1,40 +0,0 @@
<template>
<Editor ref="editor" v-model="model" autofocus :gutters="false" />
<iframe ref="iframe" class="w-full h-full border-0" sandbox="allow-same-origin allow-scripts"></iframe>
</template>
<script setup lang="ts">
const model = defineModel<string>();
const editor = useTemplateRef('editor'), iframe = useTemplateRef('iframe');
onMounted(() => {
if(iframe.value && iframe.value.contentDocument && editor.value)
{
editor.value.$el.remove();
iframe.value.contentDocument.documentElement.setAttribute('class', document.documentElement.getAttribute('class') ?? '');
iframe.value.contentDocument.documentElement.setAttribute('style', document.documentElement.getAttribute('style') ?? '');
const base = iframe.value.contentDocument.head.appendChild(iframe.value.contentDocument.createElement('base'));
base.setAttribute('href', window.location.href);
for(let element of document.getElementsByTagName('link'))
{
if(element.getAttribute('rel') === 'stylesheet')
iframe.value.contentDocument.head.appendChild(element.cloneNode(true));
}
for(let element of document.getElementsByTagName('style'))
{
iframe.value.contentDocument.head.appendChild(element.cloneNode(true));
}
iframe.value.contentDocument.body.setAttribute('class', document.body.getAttribute('class') ?? '');
iframe.value.contentDocument.body.setAttribute('style', document.body.getAttribute('style') ?? '');
iframe.value.contentDocument.body.appendChild(editor.value.$el);
editor.value.focus();
}
});
</script>

View File

@@ -1,49 +0,0 @@
<template>
<div v-if="content && content.length > 0">
<ProsesRenderer #default v-if="data" :node="data" :proses="proses" />
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue';
import { heading } from 'hast-util-heading';
import { headingRank } from 'hast-util-heading-rank';
import { parseId } from '~/shared/general.util';
import type { Root } from 'hast';
const { content, proses, filter } = defineProps<{
content: string
proses?: Record<string, string | Component>
filter?: string
}>();
const parser = useMarkdown(), data = ref<Root>();
const node = computed(() => content ? parser(content) : undefined);
watch([node], () => {
if(!node.value)
data.value = undefined;
else if(!filter)
{
data.value = node.value;
}
else
{
const start = node.value?.children.findIndex(e => heading(e) && parseId(e.properties.id as string | undefined) === filter) ?? -1;
if(start === -1)
data.value = node.value;
else
{
let end = start;
const rank = headingRank(node.value.children[start])!;
while(end < node.value.children.length)
{
end++;
if(heading(node.value.children[end]) && headingRank(node.value.children[end])! <= rank)
break;
}
data.value = { ...node.value, children: node.value.children.slice(start, end) };
}
}
}, { immediate: true, });
</script>

View File

@@ -1,115 +0,0 @@
<script lang="ts">
import type { RootContent, Root } from 'hast';
import { Text, Comment } from 'vue';
import ProseP from '~/components/prose/ProseP.vue';
import ProseA from '~/components/prose/ProseA.vue';
import ProseBlockquote from '~/components/prose/ProseBlockquote.vue';
import ProseCallout from './prose/ProseCallout.vue';
import ProseCode from '~/components/prose/ProseCode.vue';
import ProsePre from '~/components/prose/ProsePre.vue';
import ProseEm from '~/components/prose/ProseEm.vue';
import ProseH1 from '~/components/prose/ProseH1.vue';
import ProseH2 from '~/components/prose/ProseH2.vue';
import ProseH3 from '~/components/prose/ProseH3.vue';
import ProseH4 from '~/components/prose/ProseH4.vue';
import ProseH5 from '~/components/prose/ProseH5.vue';
import ProseH6 from '~/components/prose/ProseH6.vue';
import ProseHr from '~/components/prose/ProseHr.vue';
import ProseImg from '~/components/prose/ProseImg.vue';
import ProseUl from '~/components/prose/ProseUl.vue';
import ProseOl from '~/components/prose/ProseOl.vue';
import ProseLi from '~/components/prose/ProseLi.vue';
import ProseSmall from './prose/ProseSmall.vue';
import ProseStrong from '~/components/prose/ProseStrong.vue';
import ProseTable from '~/components/prose/ProseTable.vue';
import ProseTag from '~/components/prose/ProseTag.vue';
import ProseThead from '~/components/prose/ProseThead.vue';
import ProseTbody from '~/components/prose/ProseTbody.vue';
import ProseTd from '~/components/prose/ProseTd.vue';
import ProseTh from '~/components/prose/ProseTh.vue';
import ProseTr from '~/components/prose/ProseTr.vue';
import ProseScript from '~/components/prose/ProseScript.vue';
const proseList = {
"p": ProseP,
"a": ProseA,
"blockquote": ProseBlockquote,
"callout": ProseCallout,
"code": ProseCode,
"pre": ProsePre,
"em": ProseEm,
"h1": ProseH1,
"h2": ProseH2,
"h3": ProseH3,
"h4": ProseH4,
"h5": ProseH5,
"h6": ProseH6,
"hr": ProseHr,
"img": ProseImg,
"ul": ProseUl,
"ol": ProseOl,
"li": ProseLi,
"small": ProseSmall,
"strong": ProseStrong,
"table": ProseTable,
"tag": ProseTag,
"thead": ProseThead,
"tbody": ProseTbody,
"td": ProseTd,
"th": ProseTh,
"tr": ProseTr,
"script": ProseScript
};
export default defineComponent({
name: 'MarkdownRenderer',
props: {
node: {
type: Object,
required: true
},
proses: {
type: Object,
default: () => ({})
}
},
async setup(props) {
if(props.proses)
{
for(const prose of Object.keys(props.proses))
{
if(typeof props.proses[prose] === 'string')
props.proses[prose] = await resolveComponent(props.proses[prose]);
}
}
return { tags: Object.assign({}, proseList, props.proses) };
},
render(ctx: any) {
const { node, tags } = ctx;
if(!node)
return null;
return h('div', null, {default: () => (node as Root).children.map(e => renderNode(e, tags)).filter(e => !!e)});
}
});
function renderNode(node: RootContent, tags: Record<string, any>): VNode | undefined
{
if(node.type === 'text' && node.value.length > 0 && node.value !== '\n')
{
return h(Text, node.value);
}
else if(node.type === 'comment' && node.value.length > 0 && node.value !== '\n')
{
return h(Comment, node.value);
}
else if(node.type === 'element')
{
return h(tags[node.tagName] ?? node.tagName, { ...node.properties, class: node.properties.className }, { default: () => node.children.map(e => renderNode(e, tags)).filter(e => !!e) });
}
return undefined;
}
</script>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import type { CharacterConfig, MainStat, TrainingLevel } from '~/types/character';
import PreviewA from './prose/PreviewA.vue';
const { config } = defineProps<{
config: CharacterConfig
}>();
const selection = ref<{
stat: MainStat;
level: TrainingLevel;
option: number;
}>();
function focusTraining(stat: MainStat, level: TrainingLevel, option: number)
{
const s = selection.value;
if(s !== undefined && s.stat === stat && s.level === level && s.option === option)
{
selection.value = undefined;
}
else
{
selection.value = {
stat, level, option
};
}
}
</script>
<template>
<TrainingViewer :config="config" progress>
<template #default="{ stat, level, option }">
<div @click.capture="console.log" class="border border-light-40 dark:border-dark-40 hover:border-light-70 dark:hover:border-dark-70 cursor-pointer px-2 py-1 w-[400px]" :class="{ '!border-accent-blue': selection !== undefined && selection?.stat == stat && selection?.level == level && selection?.option == option }">
<MarkdownRenderer :proses="{ 'a': PreviewA }" :content="config.training[stat][level][option].description.map(e => e.text).join('\n')" />
</div>
</template>
</TrainingViewer>
</template>

View File

@@ -1,50 +0,0 @@
<script setup lang="ts">
import { MAIN_STATS, mainStatTexts, type CharacterConfig } from '~/types/character';
const { config } = defineProps<{
config: CharacterConfig
}>();
const position = ref(0);
</script>
<template>
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10 min-h-20">
<div class="flex flex-shrink gap-3 items-center relative w-48 ms-12">
<span v-for="(stat, i) of MAIN_STATS" :value="stat" class="block w-2.5 h-2.5 m-px outline outline-1 outline-transparent
hover:outline-light-70 dark:hover:outline-dark-70 rounded-full bg-light-40 dark:bg-dark-40 cursor-pointer" @click="position = i"></span>
<span :style="{ 'left': position * 1.5 + 'em' }" :data-text="mainStatTexts[MAIN_STATS[position]]" class="rounded-full w-3 h-3 bg-accent-blue absolute transition-[left]
after:content-[attr(data-text)] after:absolute after:-translate-x-1/2 after:top-4 after:p-px after:bg-light-0 dark:after:bg-dark-0 after:text-center"></span>
</div>
<div class="flex-1 flex">
<slot name="addin" :stat="MAIN_STATS[position]"></slot>
</div>
<span></span>
</div>
<div class="flex flex-1 px-8 overflow-hidden max-w-full">
<div class="relative cursor-grab active:cursor-grabbing select-none transition-[left] flex flex-1 flex-row max-w-full" :style="{ 'left': `-${position * 100}%` }">
<div class="flex flex-shrink-0 flex-col gap-4 relative w-full overflow-y-auto px-20" v-for="(stat, name) in config.training">
<template v-for="(options, level) of stat">
<div class="w-full flex h-px"><div class="border-t border-dashed border-light-50 dark:border-dark-50 w-full"></div><span class="relative left-4">{{ level }}</span></div>
<div class="flex flex-row gap-4 justify-center">
<template v-for="(option, i) in options">
<slot :stat="name" :level="level" :option="i"></slot>
</template>
</div>
</template>
</div>
<!-- <div class="flex flex-shrink-0 flex-col gap-4 relative w-full overflow-y-auto px-20" v-for="(stat, name) in config.training" >
<div class="flex flex-row gap-2 justify-center relative" v-for="(options, level) in stat">
<template v-if="progress">
<div class="absolute left-0 right-0 -top-2 h-px border-t border-light-30 dark:border-dark-30 border-dashed">
<span class="absolute right-0 p-1 text-end">{{ level }}</span>
</div>
</template>
<template v-for="(option, i) in options">
<slot :stat="name" :level="level" :option="i"></slot>
</template>
</div>
</div> -->
</div>
</div>
</template>

View File

@@ -1,80 +0,0 @@
<template>
<TreeRoot v-bind="forward" v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 overflow-auto max-h-full">
<DraggableTreeItem v-for="item in flattenItems" :key="item._id" v-bind="item" class="group flex items-center outline-none relative cursor-pointer max-w-full" @select.prevent @toggle.prevent>
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver }">
<slot :handleToggle="handleToggle"
:handleSelect="handleSelect"
:isExpanded="isExpanded"
:isSelected="isSelected"
:isDragging="isDragging"
:isDraggedOver="isDraggedOver"
:item="item"
/>
</template>
<template #hint="{ instruction }">
<div v-if="instruction">
<slot name="hint" :instruction="instruction" />
</div>
</template>
</DraggableTreeItem>
</TreeRoot>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
import { useForwardPropsEmits, type FlattenedItem, type TreeRootEmits, type TreeRootProps } from 'radix-vue';
import { type Instruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
const props = defineProps<TreeRootProps<T>>();
const emits = defineEmits<TreeRootEmits<T> & {
'updateTree': [instruction: Instruction, itemId: string, targetId: string];
}>();
defineSlots<{
default: (props: {
handleToggle: () => void,
handleSelect: () => void,
isExpanded: boolean,
isSelected: boolean,
isDragging: boolean,
isDraggedOver: boolean,
item: FlattenedItem<T>,
}) => any,
hint: (props: {
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
}) => any,
}>();
const forward = useForwardPropsEmits(props, emits);
watchEffect((onCleanup) => {
const dndFunction = combine(
monitorForElements({
onDrop(args) {
const { location, source } = args;
if (!location.current.dropTargets.length)
return;
const itemId = source.data.id as string;
const target = location.current.dropTargets[0];
const targetId = target.data.id as string;
const instruction: Instruction | null = extractInstruction(
target.data,
);
if (instruction !== null)
{
emits('updateTree', instruction, itemId, targetId);
}
},
}),
)
onCleanup(() => {
dndFunction();
})
})
</script>

View File

@@ -1,140 +0,0 @@
<template>
<TreeItem ref="el" v-bind="forward" v-slot="{ isExpanded, isSelected, isIndeterminate, handleToggle, handleSelect }">
<slot
:is-expanded="isExpanded"
:is-selected="isSelected"
:is-indeterminate="isIndeterminate"
:handle-select="handleSelect"
:handle-toggle="handleToggle"
:isDragging="isDragging"
:isDraggedOver="isDraggedOver"
/>
<div v-if="instruction">
<slot name="hint" :instruction="instruction" />
</div>
</TreeItem>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
import { useForwardPropsEmits, type FlattenedItem, type TreeItemEmits, type TreeItemProps } from 'radix-vue';
import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
const props = defineProps<TreeItemProps<T> & FlattenedItem<T>>();
const emits = defineEmits<TreeItemEmits<T>>();
defineSlots<{
default: (props: {
isExpanded: boolean
isSelected: boolean
isIndeterminate: boolean | undefined
isDragging: boolean
isDraggedOver: boolean
handleToggle: () => void
handleSelect: () => void
}) => any,
hint: (props: {
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
}) => any,
}>()
const forward = useForwardPropsEmits(props, emits);
const element = templateRef('el');
const isDragging = ref(false);
const isDraggedOver = ref(false);
const isInitialExpanded = ref(false);
const instruction = ref<Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null>(null);
const mode = computed(() => {
if (props.hasChildren)
return 'expanded'
if (props.index + 1 === props.parentItem?.children?.length)
return 'last-in-group'
return 'standard'
});
watchEffect((onCleanup) => {
const currentElement = unrefElement(element) as HTMLElement;
if (!currentElement)
return
const item = { ...props.value, level: props.level, id: props._id }
const expandItem = () => {
if (!element.value?.isExpanded) {
element.value?.handleToggle()
}
}
const closeItem = () => {
if (element.value?.isExpanded) {
element.value?.handleToggle()
}
}
const dndFunction = combine(
draggable({
element: currentElement,
getInitialData: () => item,
onDragStart: () => {
isDragging.value = true
isInitialExpanded.value = element.value?.isExpanded ?? false
closeItem()
},
onDrop: () => {
isDragging.value = false
if (isInitialExpanded.value)
expandItem()
},
}),
dropTargetForElements({
element: currentElement,
getData: ({ input, element }) => {
const data = { id: item.id }
return attachInstruction(data, {
input,
element,
indentPerLevel: 16,
currentLevel: props.level,
mode: mode.value,
block: [],
})
},
canDrop: ({ source }) => {
return source.data.id !== item.id
},
onDrag: ({ self }) => {
instruction.value = extractInstruction(self.data) as typeof instruction.value
},
onDragEnter: ({ source }) => {
if (source.data.id !== item.id) {
isDraggedOver.value = true
}
},
onDragLeave: () => {
isDraggedOver.value = false
instruction.value = null
},
onDrop: ({ location }) => {
isDraggedOver.value = false
instruction.value = null
},
getIsSticky: () => true,
}),
monitorForElements({
canMonitor: ({ source }) => {
return source.data.id !== item.id
},
}),
)
// Cleanup dnd function
onCleanup(() => dndFunction())
})
</script>

View File

@@ -1,21 +0,0 @@
<template>
<ProgressRoot class="my-2 relative overflow-hidden bg-light-25 dark:bg-dark-25 w-48 h-3 data-[shape=thin]:h-1 data-[shape=large]:h-6" :data-shape="shape" style="transform: translateZ(0)" >
<ProgressIndicator class="bg-light-50 dark:bg-dark-50 h-full w-0 transition-[width] ease-linear" :style="`transition-duration: ${delay}ms; width: ${progress ? 100 : 0}%`" />
</ProgressRoot>
</template>
<script setup lang="ts">
const { delay = 1500, decreasing = false, shape = 'normal' } = defineProps<{
delay?: number
decreasing?: boolean
shape?: 'thin' | 'normal' | 'large'
}>();
const emit = defineEmits(['finish']);
const progress = ref(false);
nextTick(() => {
progress.value = true;
setTimeout(emit, delay, 'finish');
});
</script>

View File

@@ -1,94 +0,0 @@
<template>
<ToastProvider>
<ToastRoot v-for="toast in model" :key="toast.id" :duration="toast.duration" class="ToastRoot bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 group" :open="toast.state ?? true" @update:open="(state: boolean) => tryClose(toast, state)" :data-type="toast.type ?? 'info'">
<div class="grid grid-cols-8 px-3 pt-2 pb-2">
<ToastTitle v-if="toast.title" class="font-semibold text-xl col-span-7 text-light-70 dark:text-dark-70" asChild><h4>{{ toast.title }}</h4></ToastTitle>
<ToastClose v-if="toast.closeable" aria-label="Close" class="text-xl -translate-y-2 translate-x-4 cursor-pointer"><span aria-hidden>×</span></ToastClose>
<ToastDescription v-if="toast.content" class="text-sm col-span-8 text-light-100 dark:text-dark-100" asChild><span>{{ toast.content }}</span></ToastDescription>
</div>
<TimerProgress v-if="toast.timer" shape="thin" :delay="toast.duration" class="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"
@finish="() => tryClose(toast, false)" />
</ToastRoot>
<ToastViewport class="fixed bottom-0 right-0 flex flex-col p-6 gap-2 max-w-[512px] z-50 outline-none min-w-72" />
</ToastProvider>
</template>
<script setup lang="ts">
const model = defineModel<ExtraToastConfig[]>();
function tryClose(config: ExtraToastConfig, state: boolean)
{
if(!state)
{
const m = model.value;
if(m)
{
const idx = m?.findIndex(e => e.id === config.id);
m[idx].state = false;
model.value = m;
}
setTimeout(() => model.value?.splice(model.value?.findIndex(e => e.id === config.id), 1), 500);
}
}
</script>
<style>
.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;
}
.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;
}
.ToastRoot[data-state='open'] {
animation: slideIn .15s cubic-bezier(0.16, 1, 0.3, 1);
}
.ToastRoot[data-state='closed'] {
animation: hide .1s ease-in;
}
.ToastRoot[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
.ToastRoot[data-swipe='cancel'] {
transform: translateX(0);
transition: transform .2s ease-out;
}
.ToastRoot[data-swipe='end'] {
animation: swipeRight .1s ease-out;
}
@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes slideIn {
from {
transform: translateX(calc(100% + var(--viewport-padding)));
}
to {
transform: translateX(0);
}
}
@keyframes swipeRight {
from {
transform: translateX(var(--radix-toast-swipe-end-x));
}
to {
transform: translateX(100%);
}
}
</style>

View File

@@ -1,42 +0,0 @@
<template>
<div class="absolute overflow-visible">
<div v-if="edge.label" :style="{ transform: `${labelPos} translate(-50%, -50%)` }" class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20">{{ edge.label }}</div>
<svg class="absolute top-0 overflow-visible h-px w-px">
<g :style="{'--canvas-color': edge.color?.hex}" class="z-0">
<g :style="`transform: translate(${path!.to.x}px, ${path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[path!.side]}deg);`">
<polygon :class="style.fill" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
</g>
<path :style="`stroke-width: calc(3px * var(--zoom-multiplier));`" style="stroke-linecap: butt;" :class="style.stroke" class="fill-none stroke-[4px]" :d="path!.path"></path>
</g>
</svg>
</div>
</template>
<style>
.fill-colored
{
--tw-bg-opacity: 1;
fill: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
}
</style>
<script setup lang="ts">
import { getPath, labelCenter, rotation } from '#shared/canvas.util';
import type { CanvasEdge, CanvasNode } from '~/types/canvas';
const { edge, nodes } = defineProps<{
edge: CanvasEdge
nodes: CanvasNode[]
}>();
const from = computed(() => nodes!.find(f => f.id === edge.fromNode));
const to = computed(() => nodes!.find(f => f.id === edge.toNode));
const path = computed(() => getPath(from.value!, edge.fromSide, to.value!, edge.toSide));
const labelPos = computed(() => labelCenter(from.value!, edge.fromSide, to.value!, edge.toSide));
const style = computed(() => {
return edge.color ? edge.color?.class ?
{ fill: `fill-light-${edge.color?.class} dark:fill-dark-${edge.color?.class}`, stroke: `stroke-light-${edge.color?.class} dark:stroke-dark-${edge.color?.class}` } :
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]` } :
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40` }
});
</script>

View File

@@ -1,98 +0,0 @@
<template>
<div class="absolute overflow-visible group" :class="{ 'z-[1]': focusing }">
<input v-autofocus v-if="editing" @click="e => e.stopImmediatePropagation()" :style="{ transform: `${labelPos} translate(-50%, -50%)` }" class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20" v-model="edge.label" />
<div v-else-if="edge.label" :style="{ transform: `${labelPos} translate(-50%, -50%)` }" class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20" @click.left="select" @dblclick.left="edit">{{ edge.label }}</div>
<svg ref="dom" class="absolute top-0 overflow-visible h-px w-px">
<g :style="{'--canvas-color': edge.color?.hex}" class="z-0">
<g :style="`transform: translate(${path.to.x}px, ${path.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[path.side]}deg);`">
<polygon :class="style.fill" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
</g>
<path :style="`stroke-width: calc(${focusing ? 6 : 3}px * var(--zoom-multiplier));`" style="stroke-linecap: butt;" :class="style.stroke" class="transition-[stroke-width] fill-none stroke-[4px]" :d="path.path"></path>
<path style="stroke-width: calc(22px * var(--zoom-multiplier));" class="fill-none transition-opacity z-30 opacity-0 hover:opacity-25" :class="[style.stroke, { 'opacity-25': focusing }]" :d="path.path" @click="select" @dblclick="edit"></path>
</g>
</svg>
<span v-if="focusing && !editing" :style="`transform: translate(${path.from.x}px, ${path.from.y}px) translate(-50%, -50%) scale(var(--zoom-multiplier))`" @mousedown.left="(e) => dragEdge(e, 'from')" :class="style.fill" class="hidden group-hover:block z-[31] absolute rounded-full border-2 border-light-70 dark:border-dark-70 bg-light-30 dark:bg-dark-30 w-6 h-6"></span>
<span v-if="focusing && !editing" :style="`transform: translate(${path.to.x}px, ${path.to.y}px) translate(-50%, -50%) scale(var(--zoom-multiplier))`" @mousedown.left="(e) => dragEdge(e, 'to')" :class="style.fill" class="hidden group-hover:block z-[31] absolute rounded-full border-2 border-light-70 dark:border-dark-70 bg-light-30 dark:bg-dark-30 w-6 h-6"></span>
</div>
</template>
<style>
.fill-colored
{
--tw-bg-opacity: 1;
fill: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
}
</style>
<script setup lang="ts">
import { getPath, labelCenter, rotation } from '#shared/canvas.util';
import type { Element } from '../CanvasEditor.vue';
import type { CanvasEdge, CanvasNode } from '~/types/canvas';
const { edge, nodes } = defineProps<{
edge: CanvasEdge
nodes: CanvasNode[]
}>();
const emit = defineEmits<{
(e: 'select', id: Element): void,
(e: 'edit', id: Element): void,
(e: 'drag', id: string, _e: MouseEvent, origin: 'from' | 'to'): void,
(e: 'input', id: string, text?: string): void,
}>();
const dom = useTemplateRef('dom');
const focusing = ref(false), editing = ref(false);
const from = computed(() => nodes!.find(f => f.id === edge.fromNode));
const to = computed(() => nodes!.find(f => f.id === edge.toNode));
const path = computed(() => getPath(from.value!, edge.fromSide, to.value!, edge.toSide)!);
const labelPos = computed(() => labelCenter(from.value!, edge.fromSide, to.value!, edge.toSide));
let oldText = edge.label;
function select(e: Event) {
if(editing.value)
return;
focusing.value = true;
emit('select', { type: 'edge', id: edge.id });
}
function edit(e: Event) {
oldText = edge.label;
focusing.value = true;
editing.value = true;
e.stopImmediatePropagation();
emit('edit', { type: 'edge', id: edge.id });
}
function dragEdge(e: MouseEvent, origin: 'from' | 'to') {
e.stopImmediatePropagation();
emit('drag', edge.id, e, origin);
}
function unselect() {
if(editing.value)
{
const text = edge.label;
if(text !== oldText)
{
edge.label = oldText;
emit('input', edge.id, text);
}
}
focusing.value = false;
editing.value = false;
}
defineExpose({ unselect, dom, id: edge.id, path });
const style = computed(() => {
return edge.color ? edge.color?.class ?
{ fill: `fill-light-${edge.color?.class} dark:fill-dark-${edge.color?.class}`, stroke: `stroke-light-${edge.color?.class} dark:stroke-dark-${edge.color?.class}`, outline: `outline-light-${edge.color?.class} dark:outline-dark-${edge.color?.class}` } :
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]`, outline: `outline-[color:var(--canvas-color)]` } :
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40`, outline: `outline-light-40 dark:outline-dark-40` }
});
</script>

View File

@@ -1,35 +0,0 @@
<template>
<div class="absolute" :style="{transform: `translate(${node.x}px, ${node.y}px)`, width: `${node.width}px`, height: `${node.height}px`, '--canvas-color': node.color?.hex}" :class="{'-z-10': node.type === 'group', 'z-10': node.type !== 'group'}">
<div :class="[style.border]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4">
<div class="w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto" :class="style.bg">
<div v-if="node.text?.length > 0" class="flex items-center">
<MarkdownRenderer :content="node.text" />
</div>
</div>
</div>
<div v-if="node.type === 'group' && node.label !== undefined" :class="style.border" style="max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))" class="origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] mb-2 px-2 py-1 font-thin">{{ node.label }}</div>
</div>
</template>
<style>
.bg-colored
{
--tw-bg-opacity: 1;
background-color: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
}
</style>
<script setup lang="ts">
import type { CanvasNode } from '~/types/canvas';
const { node } = defineProps<{
node: CanvasNode
zoom: number
}>();
const style = computed(() => {
return node.color ? node.color?.class ?
{ bg: `bg-light-${node.color?.class} dark:bg-dark-${node.color?.class}`, border: `border-light-${node.color?.class} dark:border-dark-${node.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` }
});
</script>

View File

@@ -1,190 +0,0 @@
<template>
<div class="absolute" ref="dom" :style="{transform: `translate(${node.x}px, ${node.y}px)`, width: `${node.width}px`, height: `${node.height}px`, '--canvas-color': node.color?.hex}" :class="{'-z-10': node.type === 'group', 'z-10': node.type !== 'group'}">
<div v-if="!editing || node.type === 'group'" style="outline-style: solid;" :class="[style.border, style.outline, { '!outline-4 cursor-move': focusing }]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4">
<div class="w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto" :class="style.bg" @click.left="(e) => { if(node.type !== 'group') selectNode(e) }" @dblclick.left="(e) => { if(node.type !== 'group') editNode(e) }">
<div v-if="node.text?.length > 0" class="flex items-center">
<MarkdownRenderer :content="node.text" :proses="{ a: FakeA }" />
</div>
</div>
<div v-if="focusing">
<span @mousedown.left="(e) => resizeNode(e, 0, 1, 0, -1)" id="n " class="cursor-n-resize absolute -top-3 -right-3 -left-3 h-6 group">
<span @mousedown.left="(e) => dragEdge(e, 'top')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -top-1.5 left-1/2 -translate-x-3"></span>
</span> <!-- North -->
<span @mousedown.left="(e) => resizeNode(e, 0, 0, 0, 1)" id="s " class="cursor-s-resize absolute -bottom-3 -right-3 -left-3 h-6 group">
<span @mousedown.left="(e) => dragEdge(e, 'bottom')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -bottom-1.5 left-1/2 -translate-x-3"></span>
</span> <!-- South -->
<span @mousedown.left="(e) => resizeNode(e, 0, 0, 1, 0)" id="e " class="cursor-e-resize absolute -top-3 -bottom-3 -right-3 w-6 group">
<span @mousedown.left="(e) => dragEdge(e, 'right')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -right-1.5 top-1/2 -translate-y-3"></span>
</span> <!-- East -->
<span @mousedown.left="(e) => resizeNode(e, 1, 0, -1, 0)" id="w " class="cursor-w-resize absolute -top-3 -bottom-3 -left-3 w-6 group">
<span @mousedown.left="(e) => dragEdge(e, 'left')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -left-1.5 top-1/2 -translate-y-3"></span>
</span> <!-- West -->
<span @mousedown.left="(e) => resizeNode(e, 1, 1, -1, -1)" id="nw" class="cursor-nw-resize absolute -top-4 -left-4 w-8 h-8"></span> <!-- North West -->
<span @mousedown.left="(e) => resizeNode(e, 0, 1, 1, -1)" id="ne" class="cursor-ne-resize absolute -top-4 -right-4 w-8 h-8"></span> <!-- North East -->
<span @mousedown.left="(e) => resizeNode(e, 0, 0, 1, 1)" id="se" class="cursor-se-resize absolute -bottom-4 -right-4 w-8 h-8"></span> <!-- South East -->
<span @mousedown.left="(e) => resizeNode(e, 1, 0, -1, 1)" id="sw" class="cursor-sw-resize absolute -bottom-4 -left-4 w-8 h-8"></span> <!-- South West -->
</div>
</div>
<div v-else style="outline-style: solid;" :class="[style.border, style.outline, { '!outline-4': focusing }]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 overflow-hidden contain-strict w-full h-full flex py-2" >
<FramedEditor v-model="node.text" autofocus :gutters="false"/>
</div>
<div v-if="!editing && node.type === 'group' && node.label !== undefined" @click.left="(e) => selectNode(e)" @dblclick.left="(e) => editNode(e)" :class="style.border" style="max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))" class="origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] mb-2 px-2 py-1 font-thin">{{ node.label }}</div>
<input v-else-if="editing && node.type === 'group'" v-model="node.label" @click="e => e.stopImmediatePropagation()" v-autofocus :class="[style.border, style.outline]" style="max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))" class="origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] appearance-none bg-transparent outline-4 mb-2 px-2 py-1 font-thin min-w-4" />
</div>
</template>
<style>
.bg-colored
{
--tw-bg-opacity: 1;
background-color: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
}
</style>
<script setup lang="ts">
import type { Box, Direction } from '#shared/canvas.util';
import type { Element } from '../CanvasEditor.vue';
import FakeA from '../prose/FakeA.vue';
import type { CanvasNode } from '~/types/canvas';
const { node, zoom, snap } = defineProps<{
node: CanvasNode
zoom: number,
snap: (activeNode: CanvasNode, resizeHandle?: Box) => Partial<Box>,
}>();
const emit = defineEmits<{
(e: 'select', id: Element): void,
(e: 'edit', id: Element): void,
(e: 'move', id: string, x: number, y: number): void,
(e: 'resize', id: string, x: number, y: number, w: number, h: number): void,
(e: 'input', id: string, text: string): void,
(e: 'edge', id: string, _e: MouseEvent, side: Direction): void,
}>();
const dom = useTemplateRef('dom');
const focusing = ref(false), editing = ref(false);
let oldText = node.type === 'group' ? node.label : node.text;
function selectNode(e: Event) {
if(editing.value)
return;
focusing.value = true;
emit('select', { type: 'node', id: node.id });
dom.value?.addEventListener('mousedown', dragstart, { passive: true });
}
function editNode(e: Event) {
focusing.value = true;
editing.value = true;
oldText = node.type === 'group' ? node.label : node.text;
e.stopImmediatePropagation();
dom.value?.removeEventListener('mousedown', dragstart);
emit('edit', { type: 'node', id: node.id });
}
function resizeNode(e: MouseEvent, x: number, y: number, w: number, h: number) {
e.stopImmediatePropagation();
const startx = node.x, starty = node.y, startw = node.width, starth = node.height;
let realx = node.x, realy = node.y, realw = node.width, realh = node.height;
const resizemove = (e: MouseEvent) => {
if(e.button !== 0)
return;
realx = realx + (e.movementX / zoom) * x;
realy = realy + (e.movementY / zoom) * y;
realw = Math.max(realw + (e.movementX / zoom) * w, 64);
realh = Math.max(realh + (e.movementY / zoom) * h, 64);
const result = e.altKey ? undefined : snap({ ...node, x: realx, y: realy, width: realw, height: realh }, { x, y, w, h });
node.x = result?.x ?? realx;
node.y = result?.y ?? realy;
node.width = result?.w ?? realw;
node.height = result?.h ?? realh;
};
const resizeend = (e: MouseEvent) => {
if(e.button !== 0)
return;
emit('resize', node.id, node.x - startx, node.y - starty, node.width - startw, node.height - starth);
window.removeEventListener('mousemove', resizemove);
window.removeEventListener('mouseup', resizeend);
}
window.addEventListener('mousemove', resizemove);
window.addEventListener('mouseup', resizeend);
}
function dragEdge(e: MouseEvent, direction: Direction) {
e.stopImmediatePropagation();
emit('edge', node.id, e, direction)
}
function unselect() {
if(editing.value)
{
const text = node.type === 'group' ? node.label : node.text;
if(text !== oldText)
{
if(node.type === 'group')
node.label = oldText;
else
node.text = oldText;
emit('input', node.id, text);
}
}
focusing.value = false;
editing.value = false;
dom.value?.removeEventListener('mousedown', dragstart);
}
let lastx = 0, lasty = 0;
let realx = 0, realy = 0;
const dragmove = (e: MouseEvent) => {
if(e.button !== 0)
return;
realx += e.movementX / zoom;
realy += e.movementY / zoom;
const result = e.altKey ? undefined : snap({ ...node, x: realx, y: realy });
node.x = result?.x ?? realx;
node.y = result?.y ?? realy;
};
const dragend = (e: MouseEvent) => {
if(e.button !== 0)
return;
window.removeEventListener('mousemove', dragmove);
window.removeEventListener('mouseup', dragend);
emit('move', node.id, node.x - lastx, node.y - lasty);
};
const dragstart = (e: MouseEvent) => {
if(e.button !== 0)
return;
lastx = node.x, lasty = node.y;
realx = node.x, realy = node.y;
window.addEventListener('mousemove', dragmove, { passive: true });
window.addEventListener('mouseup', dragend, { passive: true });
};
defineExpose({ unselect, dom, id: node.id });
const style = computed(() => {
return node.color ? node.color?.class ?
{ bg: `bg-light-${node.color?.class} dark:bg-dark-${node.color?.class}`, border: `border-light-${node.color?.class} dark:border-dark-${node.color?.class}`, outline: `outline-light-${node.color?.class} dark:outline-dark-${node.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` }
});
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,297 +0,0 @@
<script lang="ts">
import { type Position } from '#shared/canvas.util';
import { hasPermissions } from '~/shared/auth.util';
const cancelEvent = (e: Event) => e.preventDefault();
function center(touches: TouchList): Position
{
const pos = { x: 0, y: 0 };
for(const touch of touches)
{
pos.x += touch.clientX;
pos.y += touch.clientY;
}
pos.x /= touches.length;
pos.y /= touches.length;
return pos;
}
function distance(touches: TouchList): number
{
const [A, B] = touches;
return Math.hypot(B.clientX - A.clientX, B.clientY - A.clientY);
}
/*
stroke-light-red
stroke-light-orange
stroke-light-yellow
stroke-light-green
stroke-light-cyan
stroke-light-purple
dark:stroke-dark-red
dark:stroke-dark-orange
dark:stroke-dark-yellow
dark:stroke-dark-green
dark:stroke-dark-cyan
dark:stroke-dark-purple
fill-light-red
fill-light-orange
fill-light-yellow
fill-light-green
fill-light-cyan
fill-light-purple
dark:fill-dark-red
dark:fill-dark-orange
dark:fill-dark-yellow
dark:fill-dark-green
dark:fill-dark-cyan
dark:fill-dark-purple
bg-light-red
bg-light-orange
bg-light-yellow
bg-light-green
bg-light-cyan
bg-light-purple
dark:bg-dark-red
dark:bg-dark-orange
dark:bg-dark-yellow
dark:bg-dark-green
dark:bg-dark-cyan
dark:bg-dark-purple
border-light-red
border-light-orange
border-light-yellow
border-light-green
border-light-cyan
border-light-purple
dark:border-dark-red
dark:border-dark-orange
dark:border-dark-yellow
dark:border-dark-green
dark:border-dark-cyan
dark:border-dark-purple
outline-light-red
outline-light-orange
outline-light-yellow
outline-light-green
outline-light-cyan
outline-light-purple
dark:outline-dark-red
dark:outline-dark-orange
dark:outline-dark-yellow
dark:outline-dark-green
dark:outline-dark-cyan
dark:outline-dark-purple
*/
</script>
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { clamp } from '#shared/general.util';
import type { CanvasContent } from '~/types/content';
const { path } = defineProps<{
path: string
}>();
const { user } = useUserSession();
const { content, get } = useContent();
const overview = computed(() => content.value.find(e => e.path === path) as CanvasContent | undefined);
const isOwner = computed(() => user.value?.id === overview.value?.owner);
const loading = ref(false);
if(overview.value && !overview.value.content)
{
loading.value = true;
await get(path);
loading.value = false;
}
const canvas = computed(() => overview.value && overview.value.content ? overview.value.content : undefined);
console.log(canvas.value);
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef');
const updateScaleVar = useDebounceFn(() => {
if(transformRef.value)
{
transformRef.value.style.setProperty('--tw-scale', zoom.value.toString());
}
if(canvasRef.value)
{
canvasRef.value.style.setProperty('--zoom-multiplier', (1 / Math.pow(zoom.value, 0.7)).toFixed(3));
}
}, 100);
const reset = (_: MouseEvent) => {
zoom.value = minZoom.value;
dispX.value = 0;
dispY.value = 0;
updateTransform();
}
onMounted(() => {
let lastX = 0, lastY = 0, lastDistance = 0;
let box = canvasRef.value?.getBoundingClientRect()!;
const dragMove = (e: MouseEvent) => {
dispX.value = dispX.value - (lastX - e.clientX) / zoom.value;
dispY.value = dispY.value - (lastY - e.clientY) / zoom.value;
lastX = e.clientX;
lastY = e.clientY;
updateTransform();
};
const dragEnd = (e: MouseEvent) => {
window.removeEventListener('mouseup', dragEnd);
window.removeEventListener('mousemove', dragMove);
};
canvasRef.value?.addEventListener('mouseenter', () => {
window.addEventListener('wheel', cancelEvent, { passive: false });
document.addEventListener('gesturestart', cancelEvent);
document.addEventListener('gesturechange', cancelEvent);
canvasRef.value?.addEventListener('mouseleave', () => {
window.removeEventListener('wheel', cancelEvent);
document.removeEventListener('gesturestart', cancelEvent);
document.removeEventListener('gesturechange', cancelEvent);
});
})
window.addEventListener('resize', () => box = canvasRef.value?.getBoundingClientRect()!);
canvasRef.value?.addEventListener('mousedown', (e) => {
lastX = e.clientX;
lastY = e.clientY;
window.addEventListener('mouseup', dragEnd, { passive: true });
window.addEventListener('mousemove', dragMove, { passive: true });
}, { passive: true });
canvasRef.value?.addEventListener('wheel', (e) => {
if((zoom.value >= 3 && e.deltaY < 0) || (zoom.value <= minZoom.value && e.deltaY > 0))
return;
const diff = Math.exp(e.deltaY * -0.001);
const centerX = (box.x + box.width / 2), centerY = (box.y + box.height / 2);
const mousex = centerX - e.clientX, mousey = centerY - e.clientY;
dispX.value = dispX.value - (mousex / (diff * zoom.value) - mousex / zoom.value);
dispY.value = dispY.value - (mousey / (diff * zoom.value) - mousey / zoom.value);
zoom.value = clamp(zoom.value * diff, minZoom.value, 3)
updateTransform();
}, { passive: true });
canvasRef.value?.addEventListener('touchstart', (e) => {
({ x: lastX, y: lastY } = center(e.touches));
if(e.touches.length > 1)
{
lastDistance = distance(e.touches);
}
canvasRef.value?.addEventListener('touchend', touchend, { passive: true });
canvasRef.value?.addEventListener('touchcancel', touchcancel, { passive: true });
canvasRef.value?.addEventListener('touchmove', touchmove, { passive: true });
}, { passive: true });
const touchend = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: lastX, y: lastY } = center(e.touches));
}
canvasRef.value?.removeEventListener('touchend', touchend);
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
canvasRef.value?.removeEventListener('touchmove', touchmove);
};
const touchcancel = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: lastX, y: lastY } = center(e.touches));
}
canvasRef.value?.removeEventListener('touchend', touchend);
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
canvasRef.value?.removeEventListener('touchmove', touchmove);
};
const touchmove = (e: TouchEvent) => {
const pos = center(e.touches);
dispX.value = dispX.value - (lastX - pos.x) / zoom.value;
dispY.value = dispY.value - (lastY - pos.y) / zoom.value;
lastX = pos.x;
lastY = pos.y;
if(e.touches.length === 2)
{
const dist = distance(e.touches);
const diff = dist / lastDistance;
lastDistance = dist;
zoom.value = clamp(zoom.value * diff, minZoom.value, 3);
}
updateTransform();
};
updateTransform();
});
function updateTransform()
{
if(transformRef.value)
{
transformRef.value.style.transform = `scale3d(${zoom.value}, ${zoom.value}, 1) translate3d(${dispX.value}px, ${dispY.value}px, 0)`;
}
updateScaleVar();
}
</script>
<template>
<div ref="canvasRef" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none">
<div class="flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Zoom avant" side="right">
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:plus" />
</div>
</Tooltip>
<Tooltip message="Reset" side="right">
<div @click="zoom = 1; updateTransform();" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:reload" />
</div>
</Tooltip>
<Tooltip message="Tout contenir" side="right">
<div @click="reset" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:corners" />
</div>
</Tooltip>
<Tooltip message="Zoom arrière" side="right">
<div @click="zoom = clamp(zoom / 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:minus" />
</div>
</Tooltip>
</div>
<NuxtLink v-if="overview && isOwner || hasPermissions(user?.permissions ?? [], ['admin', 'editor'])" :to="{ name: 'explore-edit', hash: `#${encodeURIComponent(overview!.path)}` }" class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Modifier" side="right">
<Icon icon="radix-icons:pencil-1" class="w-8 h-8 p-2" />
</Tooltip>
</NuxtLink>
</div>
<div ref="transformRef" :style="{
'transform-origin': 'center center',
}" class="h-full">
<div v-if="canvas" class="absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none">
<div>
<CanvasNode v-for="node of canvas.nodes" :key="node.id" ref="nodes" :node="node" :zoom="zoom" />
</div>
<div>
<CanvasEdge v-for="edge of canvas.edges" :key="edge.id" ref="edges" :edge="edge" :nodes="canvas.nodes!" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,40 +0,0 @@
<script setup lang="ts">
import { hasPermissions } from '~/shared/auth.util';
const { path } = defineProps<{
path: string
filter?: string,
popover?: boolean
}>();
const { user } = useUserSession();
const { content, get } = useContent();
const overview = computed(() => content.value.find(e => e.path === path));
const isOwner = computed(() => user.value?.id === overview.value?.owner);
const loading = ref(false);
if(overview.value && !overview.value.content)
{
loading.value = true;
await get(path);
loading.value = false;
}
</script>
<template>
<div class="flex flex-1 justify-start items-start flex-col lg:px-16 xl:px-32 2xl:px-64 py-6">
<Loading v-if="loading" />
<template v-else-if="overview">
<div v-if="!popover" class="flex flex-1 flex-row justify-between items-center">
<ProseH1>{{ overview.title }}</ProseH1>
<div class="flex gap-4">
<NuxtLink :href="{ name: 'explore-edit', hash: '#' + overview.path }" v-if="isOwner || hasPermissions(user?.permissions ?? [], ['admin', 'editor'])"><Button>Modifier</Button></NuxtLink>
</div>
</div>
<MarkdownRenderer v-if="overview.content" :content="overview.content" :filter="filter" />
</template>
<template v-else>
<div><ProseH2>Impossible d'afficher le contenu demandé</ProseH2></div>
</template>
</div>
</template>

View File

@@ -1,27 +0,0 @@
<template>
<span>
<HoverCard trigger-key="Ctrl" nuxt-client class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview?.type === 'canvas'}" :disabled="!overview">
<template #content>
<Markdown v-if="overview?.type === 'markdown'" class="!px-6" :path="pathname" :filter="hash.substring(1)" popover />
<template v-else-if="overview?.type === 'canvas'"><div class="w-[600px] h-[600px] relative"><Canvas :path="pathname" /></div></template>
</template>
<span>
<span class="text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85"><slot v-bind="$attrs"></slot></span>
</span>
</HoverCard>
</span>
</template>
<script setup lang="ts">
import { parseURL } from 'ufo';
const { href } = defineProps<{
href: string
class?: string
}>();
const { hash, pathname } = parseURL(href);
const { content } = useContent();
const overview = computed(() => content.value.find(e => e.path === pathname));
</script>

View File

@@ -1,30 +0,0 @@
<template>
<span class="text-accent-blue inline-flex items-center" :class="class">
<HoverCard nuxt-client class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview?.type === 'canvas'}" :disabled="!overview">
<template #content>
<Markdown v-if="overview?.type === 'markdown'" class="!px-6" :path="decodeURIComponent(pathname)" :filter="hash.substring(1)" popover />
<template v-else-if="overview?.type === 'canvas'"><div class="w-[600px] h-[600px] relative"><Canvas :path="decodeURIComponent(pathname)" /></div></template>
</template>
<span>
<slot v-bind="$attrs"></slot>
<Icon class="w-4 h-4 inline-block" v-if="overview && overview.type !== 'markdown'" :icon="iconByType[overview.type]" />
</span>
</HoverCard>
</span>
</template>
<script setup lang="ts">
import { parseURL } from 'ufo';
import { Icon } from '@iconify/vue/dist/iconify.js';
import { iconByType } from '#shared/general.util';
const { href } = defineProps<{
href: string
class?: string
}>();
const { hash, pathname } = parseURL(href);
const { content } = useContent();
const overview = computed(() => content.value.find(e => e.path === decodeURIComponent(pathname)));
</script>

View File

@@ -1,36 +0,0 @@
<template>
<NuxtLink class="text-accent-blue inline-flex items-center" :to="overview ? { name: 'explore-path', params: { path: overview.path }, hash: decodeURIComponent(hash) } : href" :class="class">
<HoverCard nuxt-client class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview?.type === 'canvas'}" :disabled="!overview">
<template #content>
<Markdown v-if="overview?.type === 'markdown'" class="!px-6" :path="decodeURIComponent(pathname)" :filter="hash.substring(1)" popover />
<template v-else-if="overview?.type === 'canvas'"><div class="w-[600px] h-[600px] relative"><Canvas :path="decodeURIComponent(pathname)" /></div></template>
</template>
<span>
<slot v-bind="$attrs"></slot>
<Icon class="w-4 h-4 inline-block" v-if="overview && overview.type !== 'markdown'" :icon="iconByType[overview.type]" />
</span>
</HoverCard>
</NuxtLink>
</template>
<script setup lang="ts">
import { parseURL } from 'ufo';
import { Icon } from '@iconify/vue/dist/iconify.js';
import { iconByType } from '#shared/general.util';
const { href } = defineProps<{
href: string
class?: string
}>();
const { hash, pathname } = parseURL(href);
const { content } = useContent();
const overview = computed(() => content.value.find(e => e.path === decodeURIComponent(pathname)));
</script>
<style>
.cm-link {
@apply text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85;
}
</style>

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