8 Commits

103 changed files with 3892 additions and 14348 deletions

139
app.vue
View File

@@ -1,7 +1,6 @@
<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 relative">
@@ -14,14 +13,33 @@
</template>
<script setup lang="ts">
import { Content } from './shared/content.util';
import * as Floating from '#shared/floating.util';
provideToaster();
onBeforeMount(() => {
Content.init();
Floating.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();
})
});
const { list } = useToast();
</script>
<style>
::-webkit-scrollbar {
width: 12px;
width: 12px;
height: 12px;
}
@@ -39,8 +57,121 @@ const { list } = useToast();
@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;
::-webkit-scrollbar-corner {
@apply bg-transparent;
@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

@@ -5,22 +5,26 @@
"name": "d-any",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.5.0",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@codemirror/lang-markdown": "^6.3.2",
"@floating-ui/dom": "^1.6.13",
"@iconify/vue": "^4.3.0",
"@lezer/highlight": "^1.2.1",
"@markdoc/markdoc": "^0.5.1",
"@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/sitemap": "^7.2.5",
"@nuxtjs/tailwindcss": "^6.13.1",
"@types/codemirror": "^5.60.15",
"@vueuse/gesture": "^2.0.0",
"@vueuse/math": "^12.7.0",
"@vueuse/nuxt": "^12.7.0",
"codemirror": "^6.0.1",
"codemirror": "6.0.1",
"drizzle-orm": "^0.39.3",
"hast": "^1.0.0",
"hast-util-heading": "^3.0.0",
"hast-util-heading-rank": "^3.0.0",
"iconify-icon": "^2.3.0",
"lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.2",
"nodemailer": "^6.10.0",
@@ -64,6 +68,8 @@
"@atlaskit/pragmatic-drag-and-drop": ["@atlaskit/pragmatic-drag-and-drop@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.0.0", "bind-event-listener": "^3.0.0", "raf-schd": "^4.0.3" } }, "sha512-VnHcgOBALm+mbL9CoJPI6wBNQeB0is+CkejdfAlaP8RfBoELe+0sQtE8j4Z4fPRqDzo11OEqUYKHkmx4Ttzozg=="],
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": ["@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.0", "", { "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.4.0", "@babel/runtime": "^7.0.0" } }, "sha512-E52y8/0BTTf4ai6BJyFYgdVHFgQ1AES33KvAVQpZ41jMkoukLIq6UoCudOXku7xs3qoPygQdpC+vitVUuEFJXw=="],
"@atlaskit/pragmatic-drag-and-drop-hitbox": ["@atlaskit/pragmatic-drag-and-drop-hitbox@1.0.3", "", { "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.1.0", "@babel/runtime": "^7.0.0" } }, "sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA=="],
"@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="],
@@ -132,7 +138,7 @@
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="],
"@codemirror/commands": ["@codemirror/commands@6.8.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw=="],
"@codemirror/commands": ["@codemirror/commands@6.8.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ=="],
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
@@ -260,7 +266,7 @@
"@lezer/html": ["@lezer/html@1.3.10", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w=="],
"@lezer/javascript": ["@lezer/javascript@1.5.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw=="],
"@lezer/javascript": ["@lezer/javascript@1.4.21", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-lL+1fcuxWYPURMM/oFZLEDm0XuLN128QPV+VuGtKpeaOGdcl9F2LYC3nh1S9LkPqx9M0mndZFdXCipNAZpzIkQ=="],
"@lezer/lr": ["@lezer/lr@1.4.2", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA=="],
@@ -416,6 +422,8 @@
"@types/bun": ["@types/bun@1.2.2", "", { "dependencies": { "bun-types": "1.2.2" } }, "sha512-tr74gdku+AEDN5ergNiBnplr7hpDp3V1h7fqI2GcR/rsUaM39jpSeKH0TFibRvU0KwniRx5POgaYnaXbk0hU+w=="],
"@types/codemirror": ["@types/codemirror@5.60.15", "", { "dependencies": { "@types/tern": "*" } }, "sha512-dTOvwEQ+ouKJ/rE9LT1Ue2hmP6H1mZv5+CCnNWu2qtiOe2LQa9lCprEY20HxiDmV/Bxh+dXjywmy5aKvoGjULA=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
@@ -446,6 +454,8 @@
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.20", "", {}, "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="],
@@ -990,6 +1000,8 @@
"human-signals": ["human-signals@8.0.0", "", {}, "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA=="],
"iconify-icon": ["iconify-icon@2.3.0", "", { "dependencies": { "@iconify/types": "^2.0.0" } }, "sha512-C0beI9oTDxQz6voI5CKl7MiJf0Lw4UU8K4G4t6pcUDClLmCvuMOpcvd8MAztQ2SfoH0iv7WHdxBFjekKPFKH2Q=="],
"icss-replace-symbols": ["icss-replace-symbols@1.1.0", "", {}, "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg=="],
"icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="],

View File

@@ -29,12 +29,6 @@ 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 };
@@ -58,7 +52,7 @@ function distance(touches: TouchList): number
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { clamp } from '#shared/general.util';
import { clamp, getID, ID_SIZE } from '#shared/general.util';
import type { CanvasContent, CanvasEdge, CanvasNode } from '~/types/canvas';
const canvas = defineModel<CanvasContent>({ required: true });
@@ -75,12 +69,11 @@ 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 };
return { x: -dispX.value + movementX / 2, y: -dispY.value + movementY / 2, width, height };
});
const updateScaleVar = useDebounceFn(() => {
if(transformRef.value)
{
console.log(zoom.value);
transformRef.value.style.setProperty('--tw-scale', zoom.value.toString());
}
if(canvasRef.value)
@@ -100,10 +93,19 @@ 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));
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;
dispX.value = 0;
dispY.value = 0;
zoom.value = 0.5;
history.value = [];
historyPos.value = -1;
fakeEdge.value = {};
@@ -334,11 +336,10 @@ function edit(element: Element)
}
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' };
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(ID_SIZE), x, y, width, height, type: 'text' };
if(!canvas.value.nodes)
canvas.value.nodes = [node];
@@ -404,7 +405,7 @@ function dragEndEdgeTo(e: MouseEvent): void
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 };
const edge: CanvasEdge = { fromNode: fakeEdge.value.drag!.id, fromSide: fakeEdge.value.fromSide!, toNode: fakeEdge.value.snapped.node, toSide: fakeEdge.value.snapped.side, id: getID(ID_SIZE), color: node.color };
canvas.value.edges?.push(edge);
addAction('create', [{ from: undefined, to: edge, element: { id: edge.id, type: 'edge' } }]);

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,8 +1,7 @@
<template>
<CollapsibleRoot v-model:open="model" :disabled="disabled" :defaultOpen="defaultOpen">
<slot name="alwaysVisible"></slot>
<div class="flex flex-row justify-center items-center">
<span>{{ label }}<slot name="label"></slot></span>
<span v-if="!!label">{{ label }}</span>
<CollapsibleTrigger class="ms-4" asChild>
<Button icon :disabled="disabled">
<Icon v-if="model" icon="radix-icons:cross-2" class="h-4 w-4" />
@@ -10,6 +9,7 @@
</Button>
</CollapsibleTrigger>
</div>
<slot name="alwaysVisible"></slot>
<CollapsibleContent class="overflow-hidden data-[state=closed]:animate-[collapseClose_0.2s_ease-in-out] data-[state=open]:animate-[collapseOpen_0.2s_ease-in-out]">
<slot></slot>
</CollapsibleContent>

View File

@@ -1,45 +0,0 @@
<template>
<Label class="my-2 flex flex-1 items-center justify-between flex-col md:flex-row">
<span class="pb-1 md:p-0">{{ label }}</span>
<ComboboxRoot v-model:model-value="model" v-model:open="open" :multiple="multiple">
<ComboboxAnchor :disabled="disabled" class="mx-4 inline-flex min-w-[150px] items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1
bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none data-[placeholder]:font-normal
data-[placeholder]:text-light-50 dark:data-[placeholder]:text-dark-50 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
hover:border-light-50 dark:hover:border-dark-50">
<ComboboxTrigger class="flex flex-1 justify-between !cursor-pointer">
<span v-if="!multiple">{{ model !== undefined ? options.find(e => e[1] === model)![0] : "" }}</span>
<span class="flex gap-2" v-else><span v-if="model !== undefined">{{ options.find(e => e[1] === (model as T[])[0]) !== undefined ? options.find(e => e[1] === (model as T[])[0])![0] : undefined }}</span><span v-if="model !== undefined && (model as T[]).length > 1">{{((model as T[]).length > 1 ? `+${(model as T[]).length - 1}` : "") }}</span></span>
<Icon icon="radix-icons:caret-down" class="h-4 w-4" />
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal :disabled="disabled">
<ComboboxContent :position="position" align="start" class="min-w-[150px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-50">
<ComboboxViewport>
<ComboboxItem v-for="[label, value] of options" :value="value" :disabled="disabled" class="text-base py-2 leading-none text-light-60 dark:text-dark-60 flex items-center px-6 relative Combobox-none data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-light-30 dark:data-[highlighted]:bg-dark-30 data-[highlighted]:text-light-100 dark:data-[highlighted]:text-dark-100">
<span class="">{{ label }}</span>
<ComboboxItemIndicator class="absolute left-1 w-4 inline-flex items-center justify-center">
<Icon icon="radix-icons:check" />
</ComboboxItemIndicator>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</Label>
</template>
<script setup lang="ts" generic="T extends string | number | boolean | Record<string, any>">
import { ComboboxInput, ComboboxTrigger, ComboboxViewport, ComboboxContent, ComboboxPortal, ComboboxRoot } from 'radix-vue'
import { Icon } from '@iconify/vue/dist/iconify.js';
const { disabled = false, position = 'popper', multiple = false } = defineProps<{
placeholder?: string
disabled?: boolean
position?: 'inline' | 'popper'
label?: string
multiple?: boolean
options: Array<[string, T]>
}>();
const open = ref(false);
const model = defineModel<T | T[]>();
</script>

View File

@@ -1,7 +1,7 @@
<template>
<Label class="my-2 flex flex-1 items-center justify-between flex-col md:flex-row">
<span class="pb-1 md:p-0">{{ label }}</span>
<SelectRoot v-model="model" :default-value="defaultValue">
<SelectRoot v-model="model">
<SelectTrigger :disabled="disabled" class="mx-4 inline-flex min-w-[160px] items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1
bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none data-[placeholder]:font-normal
data-[placeholder]:text-light-50 dark:data-[placeholder]:text-dark-50 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
@@ -31,12 +31,11 @@
<script setup lang="ts">
import { SelectContent, SelectPortal, SelectRoot, SelectScrollDownButton, SelectScrollUpButton, SelectTrigger, SelectValue, SelectViewport } from 'radix-vue'
import { Icon } from '@iconify/vue/dist/iconify.js';
const { disabled = false, position = 'popper' } = defineProps<{
const { placeholder, disabled = false, position = 'popper', label } = defineProps<{
placeholder?: string
disabled?: boolean
position?: 'item-aligned' | 'popper'
label?: string
defaultValue?: string
}>();
const model = defineModel<string>();
</script>

View File

@@ -12,7 +12,7 @@ import { Icon } from '@iconify/vue/dist/iconify.js';
import { SelectItem, SelectItemIndicator, SelectItemText } from 'radix-vue'
const { disabled = false, value } = defineProps<{
disabled?: boolean
value: NonNullable<string>
value: NonNullable<any>
label: string
}>();
</script>

View File

@@ -2,7 +2,7 @@
<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">
<div v-if="node.text && node.text.length > 0" class="flex items-center">
<MarkdownRenderer :content="node.text" />
</div>
</div>

View File

@@ -2,7 +2,7 @@
<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">
<div v-if="node.text && node.text.length > 0" class="flex items-center">
<MarkdownRenderer :content="node.text" :proses="{ a: FakeA }" />
</div>
</div>
@@ -85,7 +85,7 @@ function editNode(e: Event) {
dom.value?.removeEventListener('mousedown', dragstart);
emit('edit', { type: 'node', id: node.id });
}
function resizeNode(e: MouseEvent, x: number, y: number, w: number, h: number) {
function resizeNode(e: MouseEvent, x: number, y: number, width: number, height: number) {
e.stopImmediatePropagation();
const startx = node.x, starty = node.y, startw = node.width, starth = node.height;
@@ -96,15 +96,15 @@ function resizeNode(e: MouseEvent, x: number, y: number, w: number, h: number) {
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);
realw = Math.max(realw + (e.movementX / zoom) * width, 64);
realh = Math.max(realh + (e.movementY / zoom) * height, 64);
const result = e.altKey ? undefined : snap({ ...node, x: realx, y: realy, width: realw, height: realh }, { x, y, w, h });
const result = e.altKey ? undefined : snap({ ...node, x: realx, y: realy, width: realw, height: realh }, { x, y, width, height });
node.x = result?.x ?? realx;
node.y = result?.y ?? realy;
node.width = result?.w ?? realw;
node.height = result?.h ?? realh;
node.width = result?.width ?? realw;
node.height = result?.height ?? realh;
};
const resizeend = (e: MouseEvent) => {
if(e.button !== 0)
@@ -127,7 +127,7 @@ function dragEdge(e: MouseEvent, direction: Direction) {
function unselect() {
if(editing.value)
{
const text = node.type === 'group' ? node.label : node.text;
const text = node.type === 'group' ? node.label! : node.text!;
if(text !== oldText)
{

View File

@@ -1,28 +1,4 @@
<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
@@ -90,9 +66,8 @@ 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';
import { Canvas } from '#shared/canvas.util';
const { path } = defineProps<{
path: string
@@ -102,196 +77,31 @@ 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();
}
const element = useTemplateRef('element');
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();
mount();
});
function updateTransform()
if(overview.value && !overview.value.content)
{
if(transformRef.value)
await get(path);
mount();
}
const canvas = computed(() => overview.value && overview.value.content ? overview.value.content : undefined);
function mount()
{
if(element.value && canvas.value)
{
transformRef.value.style.transform = `scale3d(${zoom.value}, ${zoom.value}, 1) translate3d(${dispX.value}px, ${dispY.value}px, 0)`;
const c = new Canvas(canvas.value);
element.value.appendChild(c.container);
c.mount();
}
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>
<div ref="element"></div>
</template>

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,9 +1,9 @@
<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">
<NuxtLink class="text-accent-blue inline-flex items-center" :to="overview ? { name: 'explore-path', params: { path: overview.path }, hash: hash } : href" :class="class">
<HoverCard nuxt-client class="min-w-[200px] min-h-[150px] 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>
<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>
<slot v-bind="$attrs"></slot>
@@ -16,7 +16,7 @@
<script setup lang="ts">
import { parseURL } from 'ufo';
import { Icon } from '@iconify/vue/dist/iconify.js';
import { iconByType } from '#shared/general.util';
import { iconByType } from '#shared/content.util';
const { href } = defineProps<{
href: string
@@ -26,7 +26,7 @@ const { href } = defineProps<{
const { hash, pathname } = parseURL(href);
const { content } = useContent();
const overview = computed(() => content.value.find(e => e.path === decodeURIComponent(pathname)));
const overview = computed(() => content.value.find(e => e.path === pathname));
</script>
<style>

View File

@@ -35,7 +35,6 @@ const defaultCalloutIcon = 'radix-icons:info-circled';
</script>
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue/dist/iconify.js';
const { type, title, fold } = defineProps<{
type: string;
@@ -43,104 +42,4 @@ const { type, title, fold } = defineProps<{
fold?: boolean;
}>();
const disabled = computed(() => fold === undefined);
</script>
<style>
.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;
}
</style>
</script>

View File

@@ -1,7 +1,12 @@
<template>
<span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30">
<slot></slot>
</span>
<HoverCard nuxt-client class="min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]">
<template #content>
<Markdown class="!px-6" path="tags" :filter="tag" popover />
</template>
<span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30">
<slot></slot>
</span>
</HoverCard>
</template>
<style>
@@ -13,4 +18,13 @@
{
@apply bg-accent-blue bg-opacity-10 text-accent-blue text-sm pb-0.5 pe-1 rounded-r-[12px] !rounded-se-none border border-l-0 border-accent-blue border-opacity-30;
}
</style>
</style>
<script setup lang="ts">
const { tag } = defineProps<{
tag: string
}>();
const { content } = useContent();
const overview = computed(() => content.value.find(e => e.path === "tags"));
</script>

View File

@@ -1,40 +1,45 @@
import { Content } from '~/shared/content.util';
import type { ExploreContent, ContentComposable, TreeItem } from '~/types/content';
const useContentState = () => useState<ExploreContent[]>('content', () => []);
export function useContent(): ContentComposable {
const contentState = useContentState();
return {
content: contentState,
tree: computed(() => {
const arr: TreeItem[] = [];
for(const element of contentState.value)
{
addChild(arr, element);
}
return arr;
}),
fetch,
get,
}
const contentState = useContentState();
return {
content: contentState,
tree: computed(() => {
const arr: TreeItem[] = [];
for(const element of contentState.value)
{
addChild(arr, element);
}
return arr;
}),
fetch,
get,
}
}
async function fetch(force: boolean) {
async function fetch(force: boolean = false) {
const content = useContentState();
if(content.value.length === 0 || force)
content.value = await useRequestFetch()('/api/file/overview');
}
async function get(path: string) {
async function get(path: string, force: boolean = false): Promise<ExploreContent | undefined> {
const content = useContentState()
const value = content.value;
const item = value.find(e => e.path === path);
if(item)
if(item && !item.content)
{
item.content = await useRequestFetch()(`/api/file/content/${encodeURIComponent(path)}`);
}
content.value = value;
return item;
}
function addChild(arr: TreeItem[], e: ExploreContent): void {

View File

@@ -8,9 +8,14 @@ import RemarkGfm from 'remark-gfm';
import RemarkBreaks from 'remark-breaks';
import RemarkFrontmatter from 'remark-frontmatter';
export default function useMarkdown(): (md: string) => Root
interface Parser
{
let processor: Processor;
parse: (md: string) => Promise<Root>;
parseSync: (md: string) => Root
}
export default function useMarkdown(): Parser
{
let processor: Processor, processorSync: Processor;
const parse = (markdown: string) => {
if (!processor)
@@ -19,9 +24,20 @@ export default function useMarkdown(): (md: string) => Root
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;
}
return parse;
return { parse, parseSync };
}

View File

@@ -1,9 +1,7 @@
import type { UserSession, UserSessionComposable } from '~/types/auth'
import { useContent } from './useContent'
const useSessionState = () => useState<UserSession>('nuxt-session', () => ({}))
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.

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,8 +1,7 @@
import { relations } from 'drizzle-orm';
import { int, text, sqliteTable, primaryKey, blob } from 'drizzle-orm/sqlite-core';
import { ABILITIES, MAIN_STATS } from '../types/character';
import { int, text, sqliteTable as table, primaryKey, blob } from 'drizzle-orm/sqlite-core';
export const usersTable = sqliteTable("users", {
export const usersTable = table("users", {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
email: text().notNull().unique(),
@@ -10,93 +9,50 @@ export const usersTable = sqliteTable("users", {
state: int().notNull().default(0),
});
export const usersDataTable = sqliteTable("users_data", {
export const usersDataTable = table("users_data", {
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
lastTimestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
logCount: int().notNull().default(0),
});
export const userSessionsTable = sqliteTable("user_sessions", {
export const userSessionsTable = table("user_sessions", {
id: int().notNull(),
user_id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
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 = sqliteTable("user_permissions", {
export const userPermissionsTable = table("user_permissions", {
id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
permission: text().notNull(),
}, (table) => [primaryKey({ columns: [table.id, table.permission] })]);
}, (table) => [ primaryKey({ columns: [table.id, table.permission] }) ]);
export const explorerContentTable = sqliteTable("explorer_content", {
path: text().primaryKey(),
export const projectFilesTable = table("project_files", {
id: text().primaryKey(),
path: text().notNull().unique(),
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
title: text().notNull(),
type: text({ enum: ['file', 'folder', 'markdown', 'canvas', 'map'] }).notNull(),
content: blob({ mode: 'buffer' }),
navigable: int({ mode: 'boolean' }).notNull().default(true),
private: int({ mode: 'boolean' }).notNull().default(false),
order: int().notNull(),
visit: int().notNull().default(0),
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
export const emailValidationTable = sqliteTable("email_validation", {
export const projectContentTable = table("project_content", {
id: text().primaryKey(),
content: blob({ mode: 'buffer' }),
});
export const emailValidationTable = table("email_validation", {
id: text().primaryKey(),
timestamp: int({ mode: 'timestamp' }).notNull(),
})
export const characterTable = sqliteTable("character", {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(),
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
people: int().notNull(),
level: int().notNull().default(1),
aspect: int(),
notes: text(),
health: int().notNull().default(0),
mana: int().notNull().default(0),
visibility: text({ enum: ['private', 'public'] }).notNull().default('private'),
thumbnail: blob(),
});
export const characterTrainingTable = sqliteTable("character_training", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
stat: text({ enum: MAIN_STATS }).notNull(),
level: int().notNull(),
choice: int().notNull(),
}, (table) => [primaryKey({ columns: [table.character, table.stat, table.level] })]);
export const characterLevelingTable = sqliteTable("character_leveling", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
level: int().notNull(),
choice: int().notNull(),
}, (table) => [primaryKey({ columns: [table.character, table.level] })]);
export const characterAbilitiesTable = sqliteTable("character_abilities", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
ability: text({ enum: ABILITIES }).notNull(),
value: int().notNull().default(0),
max: int().notNull().default(0),
}, (table) => [primaryKey({ columns: [table.character, table.ability] })]);
export const characterModifiersTable = sqliteTable("character_modifiers", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
modifier: text({ enum: MAIN_STATS }).notNull(),
value: int().notNull().default(0),
}, (table) => [primaryKey({ columns: [table.character, table.modifier] })]);
export const characterSpellsTable = sqliteTable("character_spell", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
value: text().notNull(),
}, (table) => [primaryKey({ columns: [table.character, table.value] })]);
export const usersRelation = relations(usersTable, ({ one, many }) => ({
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
session: many(userSessionsTable),
permission: many(userPermissionsTable),
content: many(explorerContentTable),
files: many(projectFilesTable),
}));
export const usersDataRelation = relations(usersDataTable, ({ one }) => ({
users: one(usersTable, { fields: [usersDataTable.id], references: [usersTable.id], }),
@@ -107,30 +63,9 @@ export const userSessionsRelation = relations(userSessionsTable, ({ one }) => ({
export const userPermissionsRelation = relations(userPermissionsTable, ({ one }) => ({
users: one(usersTable, { fields: [userPermissionsTable.id], references: [usersTable.id], }),
}));
export const explorerContentRelation = relations(explorerContentTable, ({ one }) => ({
users: one(usersTable, { fields: [explorerContentTable.owner], references: [usersTable.id], }),
export const projectFilesRelation = relations(projectFilesTable, ({ one }) => ({
users: one(usersTable, { fields: [projectFilesTable.owner], references: [usersTable.id], }),
}));
export const characterRelation = relations(characterTable, ({ one, many }) => ({
user: one(usersTable, { fields: [characterTable.owner], references: [usersTable.id], }),
training: many(characterTrainingTable),
levels: many(characterLevelingTable),
abilities: many(characterAbilitiesTable),
modifiers: many(characterModifiersTable),
spells: many(characterSpellsTable)
}));
export const characterTrainingRelation = relations(characterTrainingTable, ({ one }) => ({
character: one(characterTable, { fields: [characterTrainingTable.character], references: [characterTable.id] })
}));
export const characterLevelingRelation = relations(characterLevelingTable, ({ one }) => ({
character: one(characterTable, { fields: [characterLevelingTable.character], references: [characterTable.id] })
}));
export const characterAbilitiesRelation = relations(characterAbilitiesTable, ({ one }) => ({
character: one(characterTable, { fields: [characterAbilitiesTable.character], references: [characterTable.id] })
}));
export const characterModifierRelation = relations(characterModifiersTable, ({ one }) => ({
character: one(characterTable, { fields: [characterModifiersTable.character], references: [characterTable.id] })
}));
export const characterSpellsRelation = relations(characterSpellsTable, ({ one }) => ({
character: one(characterTable, { fields: [characterSpellsTable.character], references: [characterTable.id] })
export const projectContentRelation = relations(projectContentTable, ({ one }) => ({
files: one(projectFilesTable, { fields: [projectContentTable.id], references: [projectFilesTable.id], }),
}));

View File

@@ -1,7 +0,0 @@
CREATE TABLE `character` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`owner` integer NOT NULL,
`options` text NOT NULL,
FOREIGN KEY (`owner`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
);

View File

@@ -0,0 +1,21 @@
CREATE TABLE `project_content` (
`id` text PRIMARY KEY NOT NULL,
`content` blob
);
--> statement-breakpoint
CREATE TABLE `project_files` (
`id` text PRIMARY KEY NOT NULL,
`path` text NOT NULL,
`owner` integer NOT NULL,
`title` text NOT NULL,
`type` text NOT NULL,
`navigable` integer DEFAULT true NOT NULL,
`private` integer DEFAULT false NOT NULL,
`order` integer NOT NULL,
`timestamp` integer NOT NULL,
FOREIGN KEY (`owner`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_files_path_unique` ON `project_files` (`path`);--> statement-breakpoint
DROP TABLE `explorer_content`;--> statement-breakpoint
ALTER TABLE `users_data` DROP COLUMN `logCount`;

View File

@@ -1,14 +0,0 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_character` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`owner` integer NOT NULL,
`progress` text NOT NULL,
`thumbnail` blob,
FOREIGN KEY (`owner`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_character`("id", "name", "owner", "progress", "thumbnail") SELECT "id", "name", "owner", "progress", "thumbnail" FROM `character`;--> statement-breakpoint
DROP TABLE `character`;--> statement-breakpoint
ALTER TABLE `__new_character` RENAME TO `character`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@@ -1 +0,0 @@
ALTER TABLE `character` ADD `values` text DEFAULT '{}' NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE `character` ADD `visibility` text DEFAULT 'private' NOT NULL;

View File

@@ -1,47 +0,0 @@
CREATE TABLE `character_abilities` (
`character` integer NOT NULL,
`ability` text NOT NULL,
`value` integer DEFAULT 0 NOT NULL,
PRIMARY KEY(`character`, `ability`),
FOREIGN KEY (`character`) REFERENCES `character`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `character_leveling` (
`character` integer NOT NULL,
`level` integer NOT NULL,
`choice` integer NOT NULL,
PRIMARY KEY(`character`, `level`),
FOREIGN KEY (`character`) REFERENCES `character`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `character_modifiers` (
`character` integer NOT NULL,
`modifier` text NOT NULL,
`value` integer DEFAULT 0 NOT NULL,
PRIMARY KEY(`character`, `modifier`),
FOREIGN KEY (`character`) REFERENCES `character`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `character_spell` (
`character` integer PRIMARY KEY NOT NULL,
`value` text NOT NULL,
FOREIGN KEY (`character`) REFERENCES `character`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `character_training` (
`character` integer NOT NULL,
`stat` text NOT NULL,
`level` integer NOT NULL,
`choice` integer NOT NULL,
PRIMARY KEY(`character`, `stat`, `level`),
FOREIGN KEY (`character`) REFERENCES `character`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `character` ADD `people` integer NOT NULL;--> statement-breakpoint
ALTER TABLE `character` ADD `level` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE `character` ADD `aspect` integer;--> statement-breakpoint
ALTER TABLE `character` ADD `notes` text;--> statement-breakpoint
ALTER TABLE `character` ADD `health` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `character` ADD `mana` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `character` DROP COLUMN `progress`;--> statement-breakpoint
ALTER TABLE `character` DROP COLUMN `values`;

View File

@@ -1 +0,0 @@
ALTER TABLE `character_abilities` ADD `max` integer DEFAULT 0 NOT NULL;

View File

@@ -1,12 +0,0 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_character_spell` (
`character` integer NOT NULL,
`value` text NOT NULL,
PRIMARY KEY(`character`, `value`),
FOREIGN KEY (`character`) REFERENCES `character`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_character_spell`("character", "value") SELECT "character", "value" FROM `character_spell`;--> statement-breakpoint
DROP TABLE `character_spell`;--> statement-breakpoint
ALTER TABLE `__new_character_spell` RENAME TO `character_spell`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@@ -1,61 +1,9 @@
{
"version": "6",
"dialect": "sqlite",
"id": "4e31a794-f0ae-4c44-a846-6e1bafa4b247",
"id": "b24a44ad-b43a-4aba-be9c-350fd41bed04",
"prevId": "a2731c1f-4150-4423-946e-670d794f8961",
"tables": {
"character": {
"name": "character",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"options": {
"name": "options",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_owner_users_id_fk": {
"name": "character_owner_users_id_fk",
"tableFrom": "character",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_validation": {
"name": "email_validation",
"columns": {
@@ -80,13 +28,44 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"explorer_content": {
"name": "explorer_content",
"project_content": {
"name": "project_content",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_files": {
"name": "project_files",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": true,
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
@@ -111,13 +90,6 @@
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
@@ -141,14 +113,6 @@
"notNull": true,
"autoincrement": false
},
"visit": {
"name": "visit",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"timestamp": {
"name": "timestamp",
"type": "integer",
@@ -157,11 +121,19 @@
"autoincrement": false
}
},
"indexes": {},
"indexes": {
"project_files_path_unique": {
"name": "project_files_path_unique",
"columns": [
"path"
],
"isUnique": true
}
},
"foreignKeys": {
"explorer_content_owner_users_id_fk": {
"name": "explorer_content_owner_users_id_fk",
"tableFrom": "explorer_content",
"project_files_owner_users_id_fk": {
"name": "project_files_owner_users_id_fk",
"tableFrom": "project_files",
"tableTo": "users",
"columnsFrom": [
"owner"
@@ -299,14 +271,6 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"logCount": {
"name": "logCount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},

View File

@@ -1,420 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "15ea15e0-3d44-4dff-a4cd-f8666c4aa5ed",
"prevId": "4e31a794-f0ae-4c44-a846-6e1bafa4b247",
"tables": {
"character": {
"name": "character",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail": {
"name": "thumbnail",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_owner_users_id_fk": {
"name": "character_owner_users_id_fk",
"tableFrom": "character",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_validation": {
"name": "email_validation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"explorer_content": {
"name": "explorer_content",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visit": {
"name": "visit",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"explorer_content_owner_users_id_fk": {
"name": "explorer_content_owner_users_id_fk",
"tableFrom": "explorer_content",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastTimestamp": {
"name": "lastTimestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"logCount": {
"name": "logCount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"character\".\"options\"": "\"character\".\"progress\""
}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,426 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "eb68cf2f-c7e2-4111-910d-a26b0fc438cc",
"prevId": "15ea15e0-3d44-4dff-a4cd-f8666c4aa5ed",
"tables": {
"character": {
"name": "character",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"values": {
"name": "values",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{}'"
},
"thumbnail": {
"name": "thumbnail",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_owner_users_id_fk": {
"name": "character_owner_users_id_fk",
"tableFrom": "character",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_validation": {
"name": "email_validation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"explorer_content": {
"name": "explorer_content",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visit": {
"name": "visit",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"explorer_content_owner_users_id_fk": {
"name": "explorer_content_owner_users_id_fk",
"tableFrom": "explorer_content",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastTimestamp": {
"name": "lastTimestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"logCount": {
"name": "logCount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,434 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bffde16c-d716-40ec-9d92-cb49814815d7",
"prevId": "eb68cf2f-c7e2-4111-910d-a26b0fc438cc",
"tables": {
"character": {
"name": "character",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"values": {
"name": "values",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{}'"
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'private'"
},
"thumbnail": {
"name": "thumbnail",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_owner_users_id_fk": {
"name": "character_owner_users_id_fk",
"tableFrom": "character",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_validation": {
"name": "email_validation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"explorer_content": {
"name": "explorer_content",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visit": {
"name": "visit",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"explorer_content_owner_users_id_fk": {
"name": "explorer_content_owner_users_id_fk",
"tableFrom": "explorer_content",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastTimestamp": {
"name": "lastTimestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"logCount": {
"name": "logCount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,724 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "af3d9e4f-cea6-42fa-8f8b-d743d97b9c37",
"prevId": "bffde16c-d716-40ec-9d92-cb49814815d7",
"tables": {
"character_abilities": {
"name": "character_abilities",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ability": {
"name": "ability",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"character_abilities_character_character_id_fk": {
"name": "character_abilities_character_character_id_fk",
"tableFrom": "character_abilities",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_abilities_character_ability_pk": {
"columns": [
"character",
"ability"
],
"name": "character_abilities_character_ability_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_leveling": {
"name": "character_leveling",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_leveling_character_character_id_fk": {
"name": "character_leveling_character_character_id_fk",
"tableFrom": "character_leveling",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_leveling_character_level_pk": {
"columns": [
"character",
"level"
],
"name": "character_leveling_character_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_modifiers": {
"name": "character_modifiers",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"modifier": {
"name": "modifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"character_modifiers_character_character_id_fk": {
"name": "character_modifiers_character_character_id_fk",
"tableFrom": "character_modifiers",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_modifiers_character_modifier_pk": {
"columns": [
"character",
"modifier"
],
"name": "character_modifiers_character_modifier_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_spell": {
"name": "character_spell",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_spell_character_character_id_fk": {
"name": "character_spell_character_character_id_fk",
"tableFrom": "character_spell",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character": {
"name": "character",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"people": {
"name": "people",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"aspect": {
"name": "aspect",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"health": {
"name": "health",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"mana": {
"name": "mana",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'private'"
},
"thumbnail": {
"name": "thumbnail",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_owner_users_id_fk": {
"name": "character_owner_users_id_fk",
"tableFrom": "character",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_training": {
"name": "character_training",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stat": {
"name": "stat",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_training_character_character_id_fk": {
"name": "character_training_character_character_id_fk",
"tableFrom": "character_training",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_training_character_stat_level_pk": {
"columns": [
"character",
"stat",
"level"
],
"name": "character_training_character_stat_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_validation": {
"name": "email_validation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"explorer_content": {
"name": "explorer_content",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visit": {
"name": "visit",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"explorer_content_owner_users_id_fk": {
"name": "explorer_content_owner_users_id_fk",
"tableFrom": "explorer_content",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastTimestamp": {
"name": "lastTimestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"logCount": {
"name": "logCount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,732 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e0aaebf1-54e4-4f61-804b-7cce23c88069",
"prevId": "af3d9e4f-cea6-42fa-8f8b-d743d97b9c37",
"tables": {
"character_abilities": {
"name": "character_abilities",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ability": {
"name": "ability",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"max": {
"name": "max",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"character_abilities_character_character_id_fk": {
"name": "character_abilities_character_character_id_fk",
"tableFrom": "character_abilities",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_abilities_character_ability_pk": {
"columns": [
"character",
"ability"
],
"name": "character_abilities_character_ability_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_leveling": {
"name": "character_leveling",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_leveling_character_character_id_fk": {
"name": "character_leveling_character_character_id_fk",
"tableFrom": "character_leveling",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_leveling_character_level_pk": {
"columns": [
"character",
"level"
],
"name": "character_leveling_character_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_modifiers": {
"name": "character_modifiers",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"modifier": {
"name": "modifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"character_modifiers_character_character_id_fk": {
"name": "character_modifiers_character_character_id_fk",
"tableFrom": "character_modifiers",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_modifiers_character_modifier_pk": {
"columns": [
"character",
"modifier"
],
"name": "character_modifiers_character_modifier_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_spell": {
"name": "character_spell",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_spell_character_character_id_fk": {
"name": "character_spell_character_character_id_fk",
"tableFrom": "character_spell",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character": {
"name": "character",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"people": {
"name": "people",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"aspect": {
"name": "aspect",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"health": {
"name": "health",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"mana": {
"name": "mana",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'private'"
},
"thumbnail": {
"name": "thumbnail",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_owner_users_id_fk": {
"name": "character_owner_users_id_fk",
"tableFrom": "character",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_training": {
"name": "character_training",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stat": {
"name": "stat",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_training_character_character_id_fk": {
"name": "character_training_character_character_id_fk",
"tableFrom": "character_training",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_training_character_stat_level_pk": {
"columns": [
"character",
"stat",
"level"
],
"name": "character_training_character_stat_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_validation": {
"name": "email_validation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"explorer_content": {
"name": "explorer_content",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visit": {
"name": "visit",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"explorer_content_owner_users_id_fk": {
"name": "explorer_content_owner_users_id_fk",
"tableFrom": "explorer_content",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastTimestamp": {
"name": "lastTimestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"logCount": {
"name": "logCount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,740 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "cb7a2b9c-1392-4f23-9fc2-9ce8de2e0231",
"prevId": "e0aaebf1-54e4-4f61-804b-7cce23c88069",
"tables": {
"character_abilities": {
"name": "character_abilities",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ability": {
"name": "ability",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"max": {
"name": "max",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"character_abilities_character_character_id_fk": {
"name": "character_abilities_character_character_id_fk",
"tableFrom": "character_abilities",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_abilities_character_ability_pk": {
"columns": [
"character",
"ability"
],
"name": "character_abilities_character_ability_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_leveling": {
"name": "character_leveling",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_leveling_character_character_id_fk": {
"name": "character_leveling_character_character_id_fk",
"tableFrom": "character_leveling",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_leveling_character_level_pk": {
"columns": [
"character",
"level"
],
"name": "character_leveling_character_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_modifiers": {
"name": "character_modifiers",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"modifier": {
"name": "modifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"character_modifiers_character_character_id_fk": {
"name": "character_modifiers_character_character_id_fk",
"tableFrom": "character_modifiers",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_modifiers_character_modifier_pk": {
"columns": [
"character",
"modifier"
],
"name": "character_modifiers_character_modifier_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_spell": {
"name": "character_spell",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_spell_character_character_id_fk": {
"name": "character_spell_character_character_id_fk",
"tableFrom": "character_spell",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_spell_character_value_pk": {
"columns": [
"character",
"value"
],
"name": "character_spell_character_value_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character": {
"name": "character",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"people": {
"name": "people",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"aspect": {
"name": "aspect",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"health": {
"name": "health",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"mana": {
"name": "mana",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'private'"
},
"thumbnail": {
"name": "thumbnail",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_owner_users_id_fk": {
"name": "character_owner_users_id_fk",
"tableFrom": "character",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_training": {
"name": "character_training",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stat": {
"name": "stat",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_training_character_character_id_fk": {
"name": "character_training_character_character_id_fk",
"tableFrom": "character_training",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_training_character_stat_level_pk": {
"columns": [
"character",
"stat",
"level"
],
"name": "character_training_character_stat_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_validation": {
"name": "email_validation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"explorer_content": {
"name": "explorer_content",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visit": {
"name": "visit",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"explorer_content_owner_users_id_fk": {
"name": "explorer_content_owner_users_id_fk",
"tableFrom": "explorer_content",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastTimestamp": {
"name": "lastTimestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"logCount": {
"name": "logCount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -47,50 +47,8 @@
{
"idx": 6,
"version": "6",
"when": 1745072860245,
"tag": "0006_clever_marvex",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1745074613379,
"tag": "0007_tearful_true_believers",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1745675022171,
"tag": "0008_glorious_johnny_blaze",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1745920443528,
"tag": "0009_thin_omega_sentinel",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1746014143374,
"tag": "0010_bored_sabra",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1746017162319,
"tag": "0011_demonic_titania",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1746027790969,
"tag": "0012_graceful_energizer",
"when": 1743344302223,
"tag": "0006_luxuriant_blade",
"breakpoints": true
}
]

View File

@@ -1,6 +1,6 @@
<template>
<CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open">
<div class="z-50 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
<div class="z-30 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
<div class="flex items-center px-2 gap-4">
<CollapsibleTrigger asChild>
<Button icon class="!bg-transparent group md:hidden">
@@ -14,21 +14,10 @@
<span class="text-xl max-md:hidden">d[any]</span>
</NuxtLink>
</div>
<NavigationMenuRoot class="relative">
<NavigationMenuList class="flex items-center gap-8 max-md:hidden">
<NavigationMenuItem>
<NavigationMenuTrigger>
<NuxtLink :href="{ name: 'character' }" class="text-light-70 dark:text-dark-70" active-class="!text-accent-blue"><span class="pl-3 py-1 flex-1 truncate">Personnages</span></NuxtLink>
</NavigationMenuTrigger>
<NavigationMenuContent class="absolute top-0 left-0 w-full sm:w-auto bg-light-0 dark:bg-dark-0 border border-light-30 dark:border-dark-30">
<NuxtLink :href="{ name: 'character-list' }" class="text-light-70 dark:text-dark-70" active-class="!text-accent-blue"><span class="py-2 px-3 flex-1 truncate">Tous les personnages</span></NuxtLink>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
<div class="absolute top-full left-0 flex w-full justify-center my-4">
<NavigationMenuViewport class="h-[var(--radix-navigation-menu-viewport-height)] w-full origin-[top_center] overflow-hidden rounded-[10px] bg-white transition-[width,_height] duration-300 sm:w-[var(--radix-navigation-menu-viewport-width)]" />
</div>
</NavigationMenuRoot>
<div class="flex items-center gap-8 max-md:hidden">
<Tooltip message="Developpement en cours" side="bottom"><NuxtLink href="#" class="text-light-70 dark:text-dark-70">Parcourir les projets</NuxtLink></Tooltip>
<Tooltip message="Developpement en cours" side="bottom"><NuxtLink href="#" class="text-light-70 dark:text-dark-70">Créer du contenu</NuxtLink></Tooltip>
</div>
<div class="flex items-center px-2 gap-4">
<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>
@@ -40,31 +29,22 @@
</div>
</div>
<div class="flex flex-1 flex-row relative h-screen overflow-hidden">
<CollapsibleContent asChild forceMount>
<!-- <CollapsibleContent asChild forceMount> -->
<div class="bg-light-0 dark:bg-dark-0 z-40 w-screen md:w-[18rem] border-r border-light-30 dark:border-dark-30 flex flex-col justify-between my-2 max-md:data-[state=closed]:hidden">
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
<div v-if="user" class="flex flex-1 py-4 px-2 flex-row flex-1 justify-between items-center">
<NuxtLink v-if="hasPermissions(user.permissions, ['admin', 'editor'])" :to="{ name: 'explore-edit' }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink>
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="treeParent">
<div class="flex flex-row flex-1 justify-between items-center py-4 px-2">
<NuxtLink :href="{ name: 'explore-path', params: { path: 'index' } }" class="flex flex-1 font-bold text-lg items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" active-class="text-accent-blue border-s-2 !border-accent-blue">
<span class="pl-3 py-1 flex-1 truncate">Projet</span>
</NuxtLink>
<NuxtLink v-if="user && hasPermissions(user.permissions, ['admin', 'editor'])" :to="{ name: 'explore-edit' }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink>
</div>
<Tree v-if="pages" v-model="pages" :getKey="(item) => item.path" class="ps-4">
<template #default="{ item, isExpanded }">
<NuxtLink :href="item.value.path && !item.hasChildren ? { name: 'explore-path', params: { path: item.value.path } } : undefined" class="flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full" :class="{ 'font-medium': item.hasChildren }" active-class="text-accent-blue" :data-private="item.value.private">
<Icon v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level / 2 - 1.5}em` }" />
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="w-5 h-5" />
<div class="pl-1.5 py-1.5 flex-1 truncate">
{{ item.value.title }}
</div>
<Tooltip message="Privé" side="right"><Icon v-show="item.value.private" class="mx-1" icon="radix-icons:lock-closed" /></Tooltip>
</NuxtLink>
</template>
</Tree>
</div>
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60">
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
<p>Copyright Peaceultime - 2025</p>
</div>
</div>
</CollapsibleContent>
<!-- </CollapsibleContent> -->
<slot></slot>
</div>
</CollapsibleRoot>
@@ -72,10 +52,14 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { iconByType } from '#shared/general.util';
import type { DropdownOption } from '~/components/base/DropdownMenu.vue';
import { hasPermissions } from '~/shared/auth.util';
import type { TreeItem } from '~/types/content';
import { hasPermissions } from '#shared/auth.util';
import { TreeDOM } from '#shared/tree';
import { Content, iconByType } from '#shared/content.util';
import { dom, icon, text } from '#shared/dom.util';
import { unifySlug } from '#shared/general.util';
import { popper } from '#shared/floating.util';
import { link } from '#shared/proses';
const options = ref<DropdownOption[]>([{
type: 'item',
@@ -94,16 +78,43 @@ const { fetch } = useContent();
await fetch(false);
const route = useRouter().currentRoute;
const path = computed(() => route.value.params.path ? Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path : undefined);
const path = computed(() => route.value.params.path ? decodeURIComponent(unifySlug(route.value.params.path)) : undefined);
await Content.init();
const tree = new TreeDOM((item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private } }, [
icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 2 - 1.5}em` } }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
item.private ? popper(dom('span', { class: 'flex' }, [icon('radix-icons:lock-closed', { class: 'mx-1' })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }) : undefined,
])]);
}, (item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [link({ class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, active: 'text-accent-blue' }, item.path ? { name: 'explore-path', params: { path: item.path } } : undefined, [
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 ? popper(dom('span', { class: 'flex' }, [icon('radix-icons:lock-closed', { class: 'mx-1' })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }) : undefined,
])]);
}, (item) => item.navigable);
(path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(e, true));
const treeParent = useTemplateRef('treeParent');
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(e, true));
});
watch(route, () => {
open.value = false;
});
const { tree } = useContent();
const pages = computed(() => transform(tree.value));
function transform(list: TreeItem[] | undefined): TreeItem[] | undefined
{
return list?.filter(e => e.navigable)?.map(e => ({ ...e, open: path.value?.startsWith(e.path), children: transform(e.children) }));
}
onMounted(() => {
if(treeParent.value)
{
treeParent.value.appendChild(tree.container);
}
})
onUnmounted(() => {
unmount();
})
</script>

View File

@@ -16,6 +16,11 @@ export default defineNuxtConfig({
tailwindcss: {
viewer: false,
config: {
content: {
files: [
"./shared/**/*.{vue,js,jsx,mjs,ts,tsx}"
]
},
theme: {
extend: {
boxShadow: {
@@ -114,6 +119,7 @@ export default defineNuxtConfig({
pageTransition: false,
layoutTransition: false
},
ssr: false,
components: [
{
path: '~/components',
@@ -135,13 +141,12 @@ export default defineNuxtConfig({
runtimeConfig: {
session: {
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013',
maxAge: 60 * 60 * 24 *30,
maxAge: 60 * 60 * 24 * 30,
},
database: 'db.sqlite',
mail: {
host: '',
port: '',
proxy: '',
user: '',
passwd: '',
dkim: '',
@@ -162,7 +167,6 @@ export default defineNuxtConfig({
sources: ['/api/__sitemap__/urls']
},
experimental: {
buildCache: true,
componentIslands: {
selectiveClient: true,
},
@@ -180,5 +184,10 @@ export default defineNuxtConfig({
key: fs.readFileSync(path.resolve(__dirname, 'localhost+1-key.pem')).toString('utf-8'),
cert: fs.readFileSync(path.resolve(__dirname, 'localhost+1.pem')).toString('utf-8'),
}
},
vue: {
compilerOptions: {
isCustomElement: (tag) => tag === 'iconify-icon',
}
}
})

View File

@@ -8,22 +8,26 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.5.0",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@codemirror/lang-markdown": "^6.3.2",
"@floating-ui/dom": "^1.6.13",
"@iconify/vue": "^4.3.0",
"@lezer/highlight": "^1.2.1",
"@markdoc/markdoc": "^0.5.1",
"@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/sitemap": "^7.2.5",
"@nuxtjs/tailwindcss": "^6.13.1",
"@types/codemirror": "^5.60.15",
"@vueuse/gesture": "^2.0.0",
"@vueuse/math": "^12.7.0",
"@vueuse/nuxt": "^12.7.0",
"codemirror": "^6.0.1",
"codemirror": "6.0.1",
"drizzle-orm": "^0.39.3",
"hast": "^1.0.0",
"hast-util-heading": "^3.0.0",
"hast-util-heading-rank": "^3.0.0",
"iconify-icon": "^2.3.0",
"lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.2",
"nodemailer": "^6.10.0",

View File

@@ -31,7 +31,8 @@
</script>
<script setup lang="ts">
import { format, iconByType } from '~/shared/general.util';
import { format } from '~/shared/general.util';
import { iconByType } from '~/shared/content.util';
import { Icon } from '@iconify/vue/dist/iconify.js';
interface File

View File

@@ -1,381 +0,0 @@
<script lang="ts">
import config from '#shared/character-config.json';
function raceOptionToText(option: RaceOption): string
{
const text = [];
if(option.training) text.push(`+${option.training} point${option.training > 1 ? 's' : ''} de statistique${option.training > 1 ? 's' : ''}.`);
if(option.shaping) text.push(`+${option.shaping} transformation${option.shaping > 1 ? 's' : ''} par jour.`);
if(option.modifier) text.push(`+${option.modifier} au modifieur de votre choix.`);
if(option.abilities) text.push(`+${option.abilities} point${option.abilities > 1 ? 's' : ''} de compétence${option.abilities > 1 ? 's' : ''}.`);
if(option.health) text.push(`+${option.health} PV max.`);
if(option.mana) text.push(`+${option.mana} mana max.`);
if(option.spellslots) text.push(`+${option.spellslots} sort${option.spellslots > 1 ? 's' : ''} maitrisé${option.spellslots > 1 ? 's' : ''}.`);
return text.join('\n');
}
function getFeaturesOf(stat: MainStat, progression: DoubleIndex<TrainingLevel>[]): TrainingOption[]
{
const characterData = config as CharacterConfig;
return progression.map(e => characterData.training[stat][e[0]][e[1]]);
}
function abilitySpecialFeatures(type: "points" | "max", curiosity: DoubleIndex<TrainingLevel>[], value: number): number
{
if(type === 'points')
{
if(curiosity.find(e => e[0] == 6 && e[1] === 0))
return Math.max(6, value);
if(curiosity.find(e => e[0] == 6 && e[1] === 2))
return value + 1;
}
return value;
}
</script>
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import PreviewA from '~/components/prose/PreviewA.vue';
import { clamp } from '~/shared/general.util';
import { defaultCharacter, elementTexts, mainStatTexts, spellTypeTexts, type Ability, type Character, type CharacterConfig, type DoubleIndex, type Level, type MainStat, type RaceOption, type SpellConfig, type SpellElement, type SpellType, type TrainingLevel, type TrainingOption } from '~/types/character';
definePageMeta({
guestsGoesTo: '/user/login',
});
let id = useRouter().currentRoute.value.params.id;
const { add } = useToast();
const characterConfig = config as CharacterConfig;
const data = ref<Character>({ ...defaultCharacter });
const spellFilter = ref<{
ranks: Array<1 | 2 | 3>,
types: Array<SpellType>,
text: string,
elements: Array<SpellElement>,
tags: string[],
}>({
ranks: [],
types: [],
text: "",
elements: [],
tags: [],
});
const peopleOpen = ref(false), trainingOpen = ref(false), abilityOpen = ref(false), spellOpen = ref(false), notesOpen = ref(false), trainingTab = ref(0);
const raceOptions = computed(() => data.value.people !== undefined ? characterConfig.peoples[data.value.people!].options : undefined);
const selectedRaceOptions = computed(() => raceOptions !== undefined ? data.value.leveling!.map(e => raceOptions.value![e[0]][e[1]]) : undefined);
const trainingPoints = computed(() => raceOptions.value ? data.value.leveling?.reduce((p, v) => p + (raceOptions.value![v[0]][v[1]].training ?? 0), 0) : 0);
const training = computed(() => Object.entries(characterConfig.training).map(e => [e[0], getFeaturesOf(e[0] as MainStat, data.value.training[e[0] as MainStat])]) as [MainStat, TrainingOption[]][]);
const maxTraining = computed(() => Object.entries(data.value.training).reduce((p, v) => { p[v[0] as MainStat] = v[1].reduce((_p, _v) => Math.max(_p, _v[0]) , 0); return p; }, {} as Record<MainStat, number>));
const trainingSpent = computed(() => Object.values(maxTraining.value).reduce((p, v) => p + v, 0));
const modifiers = computed(() => Object.entries(maxTraining.value).reduce((p, v) => { p[v[0] as MainStat] = Math.floor(v[1] / 3) + (data.value.modifiers ? (data.value.modifiers[v[0] as MainStat] ?? 0) : 0); return p; }, {} as Record<MainStat, number>))
const modifierPoints = computed(() => (selectedRaceOptions.value ? selectedRaceOptions.value.reduce((p, v) => p + (v?.modifier ?? 0), 0) : 0) + training.value.reduce((p, v) => p + v[1].reduce((_p, _v) => _p + (_v?.modifier ?? 0), 0), 0));
const modifierSpent = computed(() => Object.values(data.value.modifiers ?? {}).reduce((p, v) => p + v, 0));
const abilityPoints = computed(() => (selectedRaceOptions.value ? selectedRaceOptions.value.reduce((p, v) => p + (v?.abilities ?? 0), 0) : 0) + training.value.flatMap(e => e[1].filter(_e => _e.ability !== undefined)).reduce((p, v) => p + v.ability!, 0));
const abilityMax = computed(() => Object.entries(characterConfig.abilities).reduce((p, v) => { p[v[0] as Ability] = abilitySpecialFeatures("max", data.value.training.curiosity, Math.floor(maxTraining.value[v[1].max[0]] / 3) + Math.floor(maxTraining.value[v[1].max[1]] / 3)); return p; }, {} as Record<Ability, number>));
const abilitySpent = computed(() => Object.values(data.value.abilities ?? {}).reduce((p, v) => p + v[0], 0));
const spellranks = computed(() => training.value.flatMap(e => e[1].filter(_e => _e.spellrank !== undefined)).reduce((p, v) => { p[v.spellrank!]++; return p; }, { instinct: 0, precision: 0, knowledge: 0 } as Record<SpellType, 0 | 1 | 2 | 3>));
const spellsPoints = computed(() => training.value.flatMap(e => e[1].filter(_e => _e.spellslot !== undefined)).reduce((p, v) => p + (modifiers.value.hasOwnProperty(v.spellslot as MainStat) ? modifiers.value[v.spellslot as MainStat] : v.spellslot as number), 0));
if(id !== 'new')
{
const character = await useRequestFetch()(`/api/character/${id}`);
if(!character)
{
throw new Error('Donnée du personnage introuvables');
}
data.value = Object.assign(defaultCharacter, data.value, character);
}
function selectRaceOption(level: Level, choice: number)
{
const character = data.value;
if(level > character.level)
return;
if(character.leveling === undefined)
character.leveling = [[1, 0]];
if(level == 1)
return;
for(let i = 1; i < level; i++) //Check previous levels as a requirement
{
if(!character.leveling.some(e => e[0] == i))
return;
}
if(character.leveling.some(e => e[0] == level))
{
character.leveling.splice(character.leveling.findIndex(e => e[0] == level), 1, [level, choice]);
}
else
{
character.leveling.push([level, choice]);
}
data.value = character;
}
function switchTrainingOption(stat: MainStat, level: TrainingLevel, choice: number)
{
const character = data.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]);
}
data.value = character;
}
function updateLevel()
{
const character = data.value;
if(character.leveling) //Invalidate higher levels
{
for(let level = 20; level > character.level; level--)
{
const index = character.leveling.findIndex(e => e[0] == level);
if(index !== -1)
character.leveling.splice(index, 1);
}
}
data.value = character;
}
function filterSpells(spells: SpellConfig[])
{
const filter = spellFilter.value
let list = [...spells];
list = list.filter(e => spellranks.value[e.type] >= e.rank);
if(filter.text.length > 0) list = list.filter(e => e.name.toLowerCase().includes(filter.text.toLowerCase()));
if(filter.types.length > 0) list = list.filter(e => filter.types.includes(e.type));
if(filter.ranks.length > 0) list = list.filter(e => filter.ranks.includes(e.rank));
if(filter.elements.length > 0) list = list.filter(e => filter.elements.some(f => e.elements.includes(f)));
if(filter.tags.length > 0) list = list.filter(e => !e.tags || filter.tags.some(f => e.tags!.includes(f)));
return list;
}
async function save(leave: boolean)
{
if(data.value.name === '' || data.value.people === undefined || data.value.people === -1)
{
add({ title: 'Données manquantes', content: "Merci de saisir un nom et une race avant de pouvoir enregistrer votre personnage", type: 'error', duration: 25000, timer: true });
return;
}
if(id === 'new')
{
id = await useRequestFetch()(`/api/character`, {
method: 'post',
body: data.value,
onResponseError: (e) => {
add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', duration: 25000, timer: true });
}
});
add({ content: 'Personnage créé', type: 'success', duration: 25000, timer: true });
useRouter().replace({ name: 'character-id-edit', params: { id: id } })
if(leave) useRouter().push({ name: 'character-id', params: { id: id } });
}
else
{
await useRequestFetch()(`/api/character/${id}`, {
method: 'post',
body: data.value,
onResponseError: (e) => {
add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', duration: 25000, timer: true });
}
});
add({ content: 'Personnage enregistré', type: 'success', duration: 25000, timer: true });
if(leave) useRouter().push({ name: 'character-id', params: { id: id } });
}
}
useShortcuts({
"Meta_S": () =>save(false),
})
</script>
<template>
<Head>
<Title>d[any] - Edition de {{ data.name || 'nouveau personnage' }}</Title>
</Head>
<div class="flex flex-col gap-8 align-center">
<div class="flex flex-row gap-4 align-center justify-between">
<div></div>
<div class="flex flex-row gap-4 align-center justify-center">
<Tooltip side="left" message="Developpement en cours"><Avatar src="" icon="radix-icons:person" size="large" /></Tooltip>
<Label class="flex items-start justify-between flex-col gap-2">
<span class="pb-1 mx-2 md:p-0">Nom du personnage</span>
<input class="caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50
bg-light-20 dark:bg-dark-20 outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20"
type="text" v-model="data.name">
</Label>
<Label class="flex items-start justify-between flex-col gap-2">
<span class="pb-1 mx-2 md:p-0">Niveau</span>
<NumberFieldRoot :min="1" :max="20" v-model="data.level" @update:model-value="updateLevel" class="flex justify-center border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 hover:border-light-50 dark:hover:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
<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-start justify-between flex-col gap-2">
<span class="pb-1 mx-6 md:p-0">Visibilité</span>
<Select class="!my-0" v-model="data.visibility">
<SelectItem label="Privé" value="private" />
<SelectItem label="Public" value="public" />
</Select>
</Label>
</div>
<div class="self-center">
<Tooltip side="right" message="Ctrl+S"><Button @click="() => save(true)">Enregistrer</Button></Tooltip>
</div>
</div>
<div class="flex flex-1 flex-col min-w-[800px] w-[75vw] max-w-[1200px]">
<Collapsible class="border-b border-light-30 dark:border-dark-30 p-1" v-model="peopleOpen" @update:model-value="() => { trainingOpen = false; abilityOpen = false; spellOpen = false; notesOpen = false; }">
<template #label>
<span class="font-bold text-xl">Peuple</span>
</template>
<template #default>
<div class="m-2 overflow-auto">
<Combobox label="Peuple de votre personnage" v-model="data.people" :options="config.peoples.map((people, index) => [people.name, index])" @update:model-value="(index) => { data.people = index as number | undefined; data.leveling = [[1, 0]]}" />
<template v-if="data.people !== undefined">
<div class="w-full border-b border-light-30 dark:border-dark-30 pb-4">
<span class="text-sm text-light-70 dark:text-dark-70">{{ characterConfig.peoples[data.people].description }}</span>
</div>
<div class="flex flex-col gap-4 max-h-[50vh] pe-4 relative">
<span class="sticky top-0 py-1 bg-light-0 dark:bg-dark-0 z-10 text-xl">Niveaux restants: {{ data.level - (data.leveling?.length ?? 0) }}</span>
<div class="flex flex-row gap-4 justify-center" v-for="(level, index) of characterConfig.peoples[data.people].options" :class="{ 'opacity-30': index > data.level }">
<div class="border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-64" v-for="(option, i) of level" @click="selectRaceOption(parseInt(index as unknown as string, 10) as Level, i)" :class="{ 'hover:border-light-60 dark:hover:border-dark-60': index <= data.level, '!border-accent-blue bg-accent-blue bg-opacity-20': data.leveling?.some(e => e[0] == index && e[1] === i) ?? false }"><MarkdownRenderer :content="raceOptionToText(option)" /></div>
</div>
</div>
</template>
</div>
</template>
</Collapsible>
<Collapsible class="border-b border-light-30 dark:border-dark-30 p-1" v-model="trainingOpen" :disabled="data.people === undefined" @update:model-value="() => { peopleOpen = false; abilityOpen = false; spellOpen = false; notesOpen = false; }">
<template #label>
<span class="font-bold text-xl">Entrainement</span>
</template>
<template #default>
<div class="flex flex-col gap-4 max-h-[50vh] pe-4 relative overflow-y-auto overflow-x-hidden">
<div class="sticky top-0 z-10 py-2 bg-light-0 dark:bg-dark-0 flex justify-between">
<Icon icon="radix-icons:caret-left" class="w-6 h-6 border border-light-30 dark:border-dark-30 cursor-pointer" @click="() => trainingTab = clamp(trainingTab - 1, 0, 6)" />
<span class="text-xl" :class="{ 'text-light-red dark:text-dark-red': (trainingPoints ?? 0) < trainingSpent }">Points d'entrainement restants: {{ (trainingPoints ?? 0) - trainingSpent }}</span>
<Icon icon="radix-icons:caret-right" class="w-6 h-6 border border-light-30 dark:border-dark-30 cursor-pointer" @click="() => trainingTab = clamp(trainingTab + 1, 0, 6)" />
</div>
<div class="flex gap-4 relative" :style="`left: calc(calc(-100% - 1em) * ${trainingTab}); transition: left .5s ease;`">
<div class="flex w-full flex-shrink-0 flex-col gap-2 relative" v-for="(text, stat) of mainStatTexts">
<div class="sticky top-1 mx-16 z-10 flex justify-between">
<div class="py-1 px-3 bg-light-0 dark:bg-dark-0 z-10 text-xl font-bold border border-light-30 dark:border-dark-30 flex">{{ text }}
<div class="flex gap-2" v-if="maxTraining[stat] >= 0">: Niveau {{ maxTraining[stat] }} (+{{ modifiers[stat] }}
<NumberFieldRoot :default-value="data.modifiers[stat] ?? 0" v-model="data.modifiers[stat]" class="flex justify-center border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 hover:border-light-50 dark:hover:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
<NumberFieldInput class="tabular-nums w-8 text-base font-normal bg-transparent px-2 outline-none caret-light-50 dark:caret-dark-50" />
</NumberFieldRoot>
)</div></div>
<div class="py-1 px-3 bg-light-0 dark:bg-dark-0 z-10 flex gap-2 justify-center items-center" :class="{ 'text-light-red dark:text-dark-red': (modifierPoints ?? 0) < modifierSpent }">Modifieur bonus: {{ modifierPoints - modifierSpent }}</div>
</div>
<div class="flex flex-row gap-4 justify-center" v-for="(level, index) of characterConfig.training[stat]" :class="{ 'opacity-30': index > maxTraining[stat] + 1 }">
<div class="border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-1/3" v-for="(option, i) of level" @click="switchTrainingOption(stat, parseInt(index as unknown as string, 10) as TrainingLevel, i)" :class="{ 'hover:border-light-60 dark:hover:border-dark-60': index <= maxTraining[stat] + 1, '!border-accent-blue bg-accent-blue bg-opacity-20': index == 0 || (data.training[stat]?.some(e => e[0] == index && e[1] === i) ?? false) }"><MarkdownRenderer :proses="{ 'a': PreviewA }" :content="option.description.map(e => e.text).join('\n')" /></div>
</div>
</div>
</div>
</div>
</template>
</Collapsible>
<Collapsible class="border-b border-light-30 dark:border-dark-30 p-1" v-model="abilityOpen" :disabled="data.people === undefined" @update:model-value="() => { trainingOpen = false; peopleOpen = false; spellOpen = false; notesOpen = false; }">
<template #label>
<span class="font-bold text-xl">Compétences</span>
</template>
<template #default>
<div class="flex flex-col gap-2 max-h-[50vh] px-4 relative overflow-y-auto">
<div class="sticky top-0 py-2 bg-light-0 dark:bg-dark-0 z-10 flex justify-between">
<span class="text-xl -mx-2" :class="{ 'text-light-red dark:text-dark-red': (abilityPoints ?? 0) < abilitySpent }">Points d'entrainement restants: {{ (abilityPoints ?? 0) - abilitySpent }}</span>
</div>
<div class="grid gap-4 grid-cols-6">
<div v-for="(ability, index) of characterConfig.abilities" class="flex flex-col items-center border border-light-30 dark:border-dark-30 p-2">
<div class="flex items-center justify-center gap-4">
<NumberFieldRoot :min="0" :default-value="data.abilities[index] ? data.abilities[index][0] : 0" @update:model-value="(value) => { data.abilities[index] = [value, data.abilities[index] ? data.abilities[index][1] : 0]; }" class="border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 hover:border-light-50 dark:hover:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
<NumberFieldInput class="tabular-nums w-8 bg-transparent px-3 py-1 outline-none caret-light-50 dark:caret-dark-50" />
</NumberFieldRoot>
<span class="font-bold col-span-4">/{{ abilityMax[index] }}</span>
</div>
<span class="text-xl font-bold flex-2">{{ ability.name }}</span>
<span class="text-sm text-light-70 dark:text-dark-70 flex-1">({{ mainStatTexts[ability.max[0]] }} + {{ mainStatTexts[ability.max[1]] }})</span>
</div>
</div>
</div>
</template>
</Collapsible>
<Collapsible class="border-b border-light-30 dark:border-dark-30 p-1" v-model="spellOpen" :disabled="data.people === undefined" @update:model-value="() => { trainingOpen = false; peopleOpen = false; abilityOpen = false; notesOpen = false; }">
<template #label>
<span class="font-bold text-xl">Sorts</span>
</template>
<template #default>
<div class="flex flex-col gap-2 max-h-[50vh] px-4 relative overflow-y-auto">
<div class="sticky top-0 py-2 bg-light-0 dark:bg-dark-0 z-10 flex gap-2 items-center">
<span class="text-xl pe-4" :class="{ 'text-light-red dark:text-dark-red': spellsPoints < (data.spells?.length ?? 0) }">Sorts: {{ data.spells?.length ?? 0 }}/{{ spellsPoints }}</span>
<TextInput label="Nom" v-model="spellFilter.text" />
<Combobox label="Rang" v-model="spellFilter.ranks" multiple :options="[['Rang 1', 1], ['Rang 2', 2], ['Rang 3', 3]]" />
<Combobox label="Type" v-model="spellFilter.types" multiple :options="[['Précision', 'precision'], ['Savoir', 'knowledge'], ['Instinct', 'instinct']]" />
<Combobox label="Element" v-model="spellFilter.elements" multiple :options="[['Feu', 'fire'], ['Glace', 'ice'], ['Foudre', 'thunder'], ['Terre', 'earth'], ['Arcane', 'arcana'], ['Air', 'air'], ['Nature', 'nature'], ['Lumière', 'light'], ['Psy', 'psyche']]" />
</div>
<div class="grid gap-4 grid-cols-2">
<div class="py-1 px-2 border border-light-30 dark:border-dark-30 flex flex-col hover:border-light-50 dark:hover:border-dark-50 cursor-pointer" v-for="spell of filterSpells(characterConfig.spells)" :class="{ '!border-accent-blue bg-accent-blue bg-opacity-20': data.spells?.find(e => e === spell.id) }"
@click="() => data.spells?.includes(spell.id) ? data.spells.splice(data.spells.findIndex((e: string) => e === spell.id), 1) : data.spells!.push(spell.id)">
<div class="flex flex-row justify-between">
<span class="text-lg font-bold">{{ spell.name }}</span>
<div class="flex flex-row items-center gap-6">
<div class="flex flex-row text-sm gap-2">
<span v-for="element of spell.elements" :class="elementTexts[element].class">{{ elementTexts[element].text }}</span>
</div>
<div class="flex flex-row text-sm gap-1">
<span class="">Rang {{ spell.rank }}</span><span>/</span>
<span class="">{{ spellTypeTexts[spell.type] }}</span><span>/</span>
<span class="">{{ spell.cost }} mana</span><span>/</span>
<span class="">{{ typeof spell.speed === 'string' ? spell.speed : `${spell.speed} minutes` }}</span>
</div>
</div>
</div>
<MarkdownRenderer :content="spell.effect" />
</div>
</div>
</div>
</template>
</Collapsible>
<Collapsible class="border-b border-light-30 dark:border-dark-30 p-1" v-model="notesOpen" @update:model-value="() => { trainingOpen = false; peopleOpen = false; abilityOpen = false; spellOpen = false; }">
<template #label>
<span class="font-bold text-xl">Notes libres</span>
</template>
<template #default>
<Editor class="min-h-[400px] border border-light-30 dark:border-dark-30" v-model="data.notes" />
</template>
</Collapsible>
</div>
</div>
</template>

View File

@@ -1,191 +0,0 @@
<script setup lang="ts">
import config from '#shared/character-config.json';
import { Icon } from '@iconify/vue/dist/iconify.js';
import PreviewA from '~/components/prose/PreviewA.vue';
import type { SpellConfig } from '~/types/character';
import { elementTexts, spellTypeTexts, type CharacterConfig } from '~/types/character';
const characterConfig = config as CharacterConfig;
const id = useRouter().currentRoute.value.params.id;
const { user } = useUserSession();
const { add } = useToast();
const { data: character, status, error } = await useFetch(`/api/character/${id}/compiled`);
</script>
<template>
<div v-if="status === 'pending'">
<Head>
<Title>d[any] - Chargement ...</Title>
</Head>
</div>
<div v-else-if="status === 'success' && character && !error">
<Head>
<Title>d[any] - {{ character.name }}</Title>
</Head>
<div class="flex flex-row gap-4 justify-between">
<div></div>
<div class="flex lg:flex-row flex-col gap-6 items-center justify-center">
<div class="flex gap-6 items-center">
<Avatar src="" icon="radix-icons:person" size="large" />
<div class="flex flex-col">
<span class="text-xl font-bold">{{ character.name }}</span>
<span class="text-sm">De {{ character.username }}</span>
</div>
<div class="flex flex-col">
<span class="font-bold">Niveau {{ character.level }}</span>
<span>{{ character.race === -1 ? "Race inconnue" : characterConfig.peoples[character.race].name }}</span>
</div>
</div>
<div class="flex gap-6 lg:border-l border-light-30 dark:border-dark-30 py-4 ps-4">
<span class="flex flex-row items-center gap-2">PV: {{ character.health - character.values.hp }}/{{ character.health }}</span>
<span class="flex flex-row items-center gap-2">Mana: {{ character.mana - character.values.mana }}/{{ character.mana }}</span>
</div>
</div>
<div class="self-center">
<Tooltip side="right" message="Modifier" v-if="user && user.id === character.owner"><NuxtLink :to="{ name: 'character-id-edit', params: { id: character.id } }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink></Tooltip>
</div>
</div>
<div class="flex flex-1 flex-col justify-center gap-4 *:py-2">
<div class="grid 2xl:grid-cols-12 grid-cols-2 gap-4 items-center border-b border-light-30 dark:border-dark-30">
<div class="flex relative justify-between ps-4 gap-2 2xl:col-span-6 lg:col-span-2">
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.strength }}</span><span class="text-sm 2xl:text-base">Force</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.dexterity }}</span><span class="text-sm 2xl:text-base">Dextérité</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.constitution }}</span><span class="text-sm 2xl:text-base">Constitution</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.intelligence }}</span><span class="text-sm 2xl:text-base">Intelligence</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.curiosity }}</span><span class="text-sm 2xl:text-base">Curiosité</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.charisma }}</span><span class="text-sm 2xl:text-base">Charisme</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.psyche }}</span><span class="text-sm 2xl:text-base">Psyché</span></div>
</div>
<div class="flex relative 2xl:border-l border-light-30 dark:border-dark-30 ps-4 2xl:col-span-2">
<div class="flex flex-1 flex-row items-center justify-between">
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">+{{ character.initiative }}</span><span>Initiative</span></div>
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">{{ character.speed === false ? "Aucun déplacement" : `${character.speed} cases` }}</span><span>Course</span></div>
</div>
<!-- <div class="absolute top-0 left-0 bottom-0 right-0 bg-light-0 dark:bg-dark-0 bg-opacity-50 dark:bg-opacity-50 text-xl font-bold flex items-center justify-center">Les données secondaires arrivent bientôt.</div> -->
</div>
<div class="flex relative border-l border-light-30 dark:border-dark-30 ps-4 2xl:col-span-4">
<div class="flex flex-col px-2">
<span class="text-xl">Défense passive: <span class="text-2xl font-bold">{{ character.defense.static }}</span>/+<span class="text-2xl font-bold">{{ character.defense.passivedodge }}</span>/+<span class="text-2xl font-bold">{{ character.defense.passiveparry }}</span></span>
<span class="text-xl">Défense active: <span class="float-right">+<span class="text-2xl font-bold">{{ character.defense.activedodge }}</span>/+<span class="text-2xl font-bold">{{ character.defense.activeparry }}</span></span></span>
</div>
</div>
</div>
<div class="flex flex-1 px-8">
<div class="flex flex-col pe-8 gap-4 py-8 w-80 border-r border-light-30 dark:border-dark-30">
<div class="flex flex-col">
<span class="text-lg font-semibold border-b border-light-30 dark:border-dark-30">Maitrise d'arme</span>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
<PreviewA v-if="character.mastery.strength + character.mastery.dexterity > 0" href="1. Règles/99. Annexes/4. Équipement#Les armes légères">Arme légère</PreviewA>
<PreviewA v-if="character.mastery.strength + character.mastery.dexterity > 0" href="1. Règles/99. Annexes/4. Équipement#Les armes de jet">Arme de jet</PreviewA>
<PreviewA v-if="character.mastery.strength + character.mastery.dexterity > 0" href="1. Règles/99. Annexes/4. Équipement#Les armes naturelles">Arme naturelle</PreviewA>
<PreviewA v-if="character.mastery.strength > 1" href="1. Règles/99. Annexes/4. Équipement#Les armes">Arme standard</PreviewA>
<PreviewA v-if="character.mastery.strength > 1" href="1. Règles/99. Annexes/4. Équipement#Les armes improvisées">Arme improvisée</PreviewA>
<PreviewA v-if="character.mastery.strength > 2" href="1. Règles/99. Annexes/4. Équipement#Les armes lourdes">Arme lourde</PreviewA>
<PreviewA v-if="character.mastery.strength > 3" href="1. Règles/99. Annexes/4. Équipement#Les armes à deux mains">Arme à deux mains</PreviewA>
<PreviewA v-if="character.mastery.dexterity > 0 && character.mastery.strength > 1" href="1. Règles/99. Annexes/4. Équipement#Les armes maniables">Arme maniable</PreviewA>
<PreviewA v-if="character.mastery.dexterity > 1 && character.mastery.strength > 1" href="1. Règles/99. Annexes/4. Équipement#Les armes à projectiles">Arme à projectiles</PreviewA>
<PreviewA v-if="character.mastery.dexterity > 1 && character.mastery.strength > 2" href="1. Règles/99. Annexes/4. Équipement#Les armes longues">Arme longue</PreviewA>
<PreviewA v-if="character.mastery.shield > 0" href="1. Règles/99. Annexes/4. Équipement#Les boucliers">Bouclier</PreviewA>
<PreviewA v-if="character.mastery.shield > 0 && character.mastery.strength > 3" href="1. Règles/99. Annexes/4. Équipement#Les boucliers à deux mains">Bouclier à deux mains</PreviewA>
</div>
</div>
<div v-if="character.mastery.armor > 0" class="flex flex-col">
<span class="text-lg font-semibold border-b border-light-30 dark:border-dark-30">Maitrise d'armure</span>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
<PreviewA v-if="character.mastery.armor > 0" href="1. Règles/99. Annexes/4. Équipement#Les armures légères">Armure légère</PreviewA>
<PreviewA v-if="character.mastery.armor > 1" href="1. Règles/99. Annexes/4. Équipement#Les armures">Armure standard</PreviewA>
<PreviewA v-if="character.mastery.armor > 2" href="1. Règles/99. Annexes/4. Équipement#Les armures lourdes">Armure lourde</PreviewA>
</div>
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold border-b border-light-30 dark:border-dark-30">Maitrise de sorts</span>
<span>Sorts de précision: <span class="font-bold">{{ character.spellranks.precision }}</span></span>
<span>Sorts de savoir: <span class="font-bold">{{ character.spellranks.knowledge }}</span></span>
<span>Sorts d'instinct: <span class="font-bold">{{ character.spellranks.instinct }}</span></span>
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold border-b border-light-30 dark:border-dark-30 mb-2 flex items-center gap-4">Résistances (Attaque/Défense) <Tooltip side="right" message="Les défenses affichées incluent déjà leur modifieur de statistique."><Icon icon="radix-icons:question-mark-circled" /></Tooltip></span>
<div class="grid grid-cols-3 gap-1">
<div class="flex flex-col px-2 items-center text-sm text-light-70 dark:text-dark-70" v-for="(value, resistance) of character.resistance"><span class="font-bold text-base text-light-100 dark:text-dark-100">+{{ value[0] }}/+{{ value[1] + character.modifier[characterConfig.resistances[resistance].statistic as MainStat] }}</span><span>{{ characterConfig.resistances[resistance].name }}</span></div>
</div>
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold border-b border-light-30 dark:border-dark-30 mb-2">Compétences</span>
<div class="grid grid-cols-3 gap-1">
<div class="flex flex-col px-2 items-center text-sm text-light-70 dark:text-dark-70" v-for="(value, ability) of character.abilities"><span class="font-bold text-base text-light-100 dark:text-dark-100">+{{ value }}</span><span>{{ characterConfig.abilities[ability].name }}</span></div>
</div>
</div>
</div>
<TabsRoot default-value="features" class="w-[60rem]">
<TabsList class="flex flex-row gap-4 relative px-4">
<TabsIndicator class="absolute px-8 left-0 h-[3px] bottom-0 w-[--radix-tabs-indicator-size] translate-x-[--radix-tabs-indicator-position] transition-[width,transform] duration-300 bg-accent-blue"></TabsIndicator>
<TabsTrigger value="features" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Aptitudes</TabsTrigger>
<TabsTrigger value="spells" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Sorts</TabsTrigger>
<TabsTrigger value="notes" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Notes</TabsTrigger>
</TabsList>
<TabsContent value="features">
<div class="flex flex-1 flex-col ps-8 gap-4 py-8">
<div class="grid grid-cols-3 gap-2">
<div class="flex flex-col">
<span class="text-lg font-semibold">Actions</span>
<span class="text-sm text-light-70 dark:text-dark-70">Attaquer - Saisir - Faire chuter - Déplacer - Courir - Pas de coté - Lancer un sort - S'interposer - Se transformer - Utiliser un objet - Anticiper une action - Improviser</span>
<MarkdownRenderer :content="character.features.action.join('\n')" />
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold">Réactions</span>
<span class="text-sm text-light-70 dark:text-dark-70">Parade - Esquive - Saisir une opportunité - Prendre en tenaille - Intercepter - Désarmer</span>
<MarkdownRenderer :content="character.features.reaction.join('\n')" />
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold">Actions libre</span>
<span class="text-sm text-light-70 dark:text-dark-70">Analyser une situation - Communiquer</span>
<MarkdownRenderer :content="character.features.freeaction.join('\n')" />
</div>
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold">Aptitudes</span>
<MarkdownRenderer :content="character.features.misc.map(e => `> ${e}`).join('\n\n')" />
</div>
</div>
</TabsContent>
<TabsContent v-if="character.spells.length > 0" value="spells">
<div class="flex flex-1 flex-col ps-8 gap-4 py-8">
<div class="flex flex-col">
<div class="pb-4 px-2 mt-4 border-b last:border-none border-light-30 dark:border-dark-30 flex flex-col" v-for="spell of character.spells.map(e => characterConfig.spells.find((f: SpellConfig) => f.id === e)).filter(e => !!e)">
<div class="flex flex-row justify-between">
<span class="text-lg font-bold">{{ spell.name }}</span>
<div class="flex flex-row items-center gap-6">
<div class="flex flex-row text-sm gap-2">
<span v-for="element of spell.elements" :class="elementTexts[element].class">{{ elementTexts[element].text }}</span>
</div>
<div class="flex flex-row text-sm gap-1">
<span class="">Rang {{ spell.rank }}</span><span>/</span>
<span class="">{{ spellTypeTexts[spell.type] }}</span><span>/</span>
<span class="">{{ spell.cost }} mana</span><span>/</span>
<span class="">{{ typeof spell.speed === 'string' ? spell.speed : `${spell.speed} minutes` }}</span>
</div>
</div>
</div>
<MarkdownRenderer :content="spell.effect" />
</div>
</div>
</div>
</TabsContent>
<TabsContent value="notes">
<div class="flex flex-1 flex-col ps-8 gap-4 py-8">
<MarkdownRenderer :content="character.notes" />
</div>
</TabsContent>
</TabsRoot>
</div>
</div>
</div>
<div v-else>
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<div>Erreur de chargement</div>
</div>
</template>

View File

@@ -1,95 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
definePageMeta({
guestsGoesTo: '/user/login',
})
const { add } = useToast();
const { user } = useUserSession();
const { data: characters, error, status } = await useFetch(`/api/character`);
async function deleteCharacter(id: number)
{
status.value = "pending";
await useRequestFetch()(`/api/character/${id}`, { method: 'delete' });
status.value = "success";
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";
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 class="flex align-center justify-center">
<NuxtLink v-if="user?.state === 1" :to="{ name: 'character-id-edit', params: { id: 'new' } }"><Button>Nouveau personnage</Button></NuxtLink>
<Tooltip v-else side="top" message="Veuillez valider votre email avant de pouvoir créer un personnage."><Button disabled>Nouveau personnage</Button></Tooltip>
</div>
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
<Loading size="large" />
</div>
<div v-else-if="status === 'success'" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
<div class="border border-light-30 dark:border-dark-30 p-3 flex flex-row gap-4" v-for="character of characters">
<Avatar size="large" icon="radix-icons:person" src="" />
<div class="flex flex-1 flex-shrink flex-col truncate">
<NuxtLink class="text-xl font-bold hover:text-accent-blue truncate" :to="{ name: 'character-id', params: { id: character.id } }" :title="character.name">{{ character.name }}</NuxtLink>
<span class="text-sm truncate">Niveau {{ character.level }}</span>
</div>
<AlertDialogRoot>
<DropdownMenuRoot>
<DropdownMenuTrigger class="self-start">
<Button icon><Icon icon="radix-icons:dots-vertical" /></Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end" side="bottom" class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
<DropdownMenuItem @select="useRouter().push({ name: 'character-id-edit', params: { id: character.id } })" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-baseline py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<Icon icon="radix-icons:pencil-1" class="absolute left-1.5" />
<span>Editer</span>
</DropdownMenuItem>
<DropdownMenuItem @select="duplicateCharacter(character.id)" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<Icon icon="radix-icons:clipboard-copy" class="absolute left-1.5" />
<span>Dupliquer</span>
</DropdownMenuItem>
<AlertDialogTrigger>
<DropdownMenuItem class="cursor-pointer text-base text-light-red dark:text-dark-red leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-red dark:data-[highlighted]:bg-dark-red data-[highlighted]:bg-opacity-30 dark:data-[highlighted]:bg-opacity-30">
<Icon icon="radix-icons:trash" class="absolute left-1.5" />
<span>Supprimer</span>
</DropdownMenuItem>
</AlertDialogTrigger>
<DropdownMenuArrow class="fill-light-35 dark:fill-dark-35" />
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<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 v-else>
<span>Erreur de chargement</span>
<span>{{ error?.message }}</span>
</div>
</div>
</template>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
const { data: characters, error, status } = await useFetch(`/api/character`, { params: { visibility: "public" } });
</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>
<div v-else-if="status === 'success'" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
<div class="border border-light-30 dark:border-dark-30 p-3 flex flex-row gap-4" v-for="character of characters">
<Avatar size="large" icon="radix-icons:person" src="" />
<div class="flex flex-1 flex-shrink flex-col truncate">
<NuxtLink class="text-xl font-bold hover:text-accent-blue truncate" :to="{ name: 'character-id', params: { id: character.id } }" :title="character.name">{{ character.name }}</NuxtLink>
<span class="text-sm truncate">Niveau {{ character.progress.level }}</span>
</div>
</div>
</div>
<div v-else>
<span>Erreur de chargement</span>
<span>{{ error?.message }}</span>
</div>
</div>
</template>

View File

@@ -1,24 +1,23 @@
<template>
<div class="flex flex-1 justify-start items-start" v-if="overview">
<div class="flex flex-1 justify-start items-start" ref="element">
<Head>
<Title>d[any] - {{ overview.title }}</Title>
<Title>d[any] - {{ overview?.title ?? "Erreur" }}</Title>
</Head>
<Markdown v-if="overview.type === 'markdown'" :path="path" />
<Canvas v-else-if="overview.type === 'canvas'" :path="path" />
<ProseH2 v-else class="flex-1 text-center">Impossible d'afficher le contenu demandé</ProseH2>
</div>
<div v-else>
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<div><ProseH2>Impossible d'afficher le contenu demandé</ProseH2></div>
</div>
</template>
<script setup lang="ts">
const route = useRouter().currentRoute;
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
import { Content } from '#shared/content.util';
import { unifySlug } from '#shared/general.util';
const { content } = useContent();
const overview = computed(() => content.value.find(e => e.path === path.value));
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 && await Content.ready)
{
overview.value = Content.render(element.value, path.value);
}
});
</script>

View File

@@ -2,228 +2,113 @@
<Head>
<Title>d[any] - Modification</Title>
</Head>
<ClientOnly>
<CollapsibleRoot asChild class="flex flex-1 flex-col xl:-mx-12 xl:-my-8 lg:-mx-8 lg:-my-6 -mx-6 -my-3 overflow-hidden" v-model="open">
<div>
<div class="z-50 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
<div class="flex items-center px-2 gap-4">
<CollapsibleTrigger asChild>
<Button icon class="!bg-transparent group md:hidden">
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" />
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
</Button>
</CollapsibleTrigger>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-opacity-70 m-2 flex items-center gap-4" aria-label="Accueil" :to="{ path: '/', force: true }">
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
<Avatar src="/logo.light.svg" class="block dark:hidden" />
<span class="text-xl max-md:hidden">d[any]</span>
</NuxtLink>
</div>
<div class="flex items-center px-2 gap-4">
<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 relative overflow-hidden">
<CollapsibleContent asChild forceMount>
<div class="bg-light-0 dark:bg-dark-0 z-40 w-screen md:w-[18rem] border-r border-light-30 dark:border-dark-30 flex flex-col justify-between my-2 max-md:data-[state=closed]:hidden">
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex flex-row justify-between items-center pt-2 pb-4 mb-2 px-2 gap-4 border-b border-light-35 dark:border-dark-35">
<Button @click="router.push({ name: 'explore-path', params: { path: selected ? getPath(selected) : 'index' } })">Quitter</Button>
<Button @click="save(true);">Enregistrer</Button>
<Tooltip side="top" message="Nouveau">
<DropdownMenu align="end" side="bottom" :options="[{
type: 'item',
label: 'Markdown',
kbd: 'Ctrl+N',
icon: 'radix-icons:file-text',
select: () => add('markdown'),
}, {
type: 'item',
label: 'Dossier',
kbd: 'Ctrl+Shift+N',
icon: 'lucide:folder',
select: () => add('folder'),
}, {
type: 'item',
label: 'Canvas',
icon: 'ph:graph-light',
select: () => add('canvas'),
}, {
type: 'item',
label: 'Carte',
icon: 'lucide:map',
select: () => add('map'),
}, {
type: 'item',
label: 'Fichier',
icon: 'radix-icons:file',
select: () => add('file'),
}]">
<Button icon><Icon class="w-5 h-5" icon="radix-icons:plus" /></Button>
</DropdownMenu>
</Tooltip>
</div>
<DraggableTree class="ps-4 text-sm" :items="navigation ?? undefined" :get-key="(item: Partial<TreeItemEditable>) => item.path !== undefined ? getPath(item as TreeItemEditable) : ''" @updateTree="drop"
v-model="selected" :defaultExpanded="defaultExpanded" :get-children="(item: Partial<TreeItemEditable>) => item.type === 'folder' ? item.children : undefined" >
<template #default="{ handleToggle, handleSelect, isExpanded, isDragging, item }">
<div class="flex flex-1 items-center overflow-hidden" :class="{ 'opacity-50': isDragging }" :style="{ 'padding-left': `${item.level / 2 - 0.5}em` }">
<div class="flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple group-data-[selected]:text-accent-blue">
<Icon @click="handleToggle" v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level / 2 - 1.5}em` }" />
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="w-5 h-5" @click="handleSelect" />
<div class="pl-1.5 py-1.5 flex-1 truncate" :title="item.value.title" @click="handleSelect" :class="{ 'font-semibold': item.hasChildren }">
{{ item.value.title }}
</div>
</div>
<div class="flex gap-2">
<span @click="item.value.private = !item.value.private">
<Icon v-if="item.value.private" icon="radix-icons:lock-closed" />
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:lock-open-2" />
</span>
<span @click="item.value.navigable = !item.value.navigable">
<Icon v-if="item.value.navigable" icon="radix-icons:eye-open" />
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:eye-none" />
</span>
</div>
</div>
</template>
<template #hint="{ instruction }">
<div v-if="instruction" class="absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50" :style="{
width: `calc(100% - ${instruction.currentLevel / 2 - 1.5}em)`
}" :class="{
'!border-b-4': instruction?.type === 'reorder-below',
'!border-t-4': instruction?.type === 'reorder-above',
'!border-4': instruction?.type === 'make-child',
}"></div>
</template>
</DraggableTree>
</div>
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60">
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
<p>Copyright Peaceultime - 2025</p>
</div>
</div>
</CollapsibleContent>
<div class="flex flex-1 flex-row max-h-full overflow-hidden">
<div v-if="selected" class="flex flex-1 flex-col items-start justify-start max-h-full relative">
<Head>
<Title>d[any] - Modification de {{ selected.title }}</Title>
</Head>
<CollapsibleRoot v-model:open="topOpen" class="group data-[state=open]:mt-4 w-full relative">
<CollapsibleTrigger asChild>
<Button class="absolute left-1/2 -translate-x-1/2 group-data-[state=open]:-bottom-3 group-data-[state=closed]:-bottom-6 z-30" icon>
<Icon v-if="topOpen" icon="radix-icons:caret-up" class="h-4 w-4" />
<Icon v-else icon="radix-icons:caret-down" class="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="xl:px-12 lg:px-8 px-6">
<div class="pb-2 grid lg:grid-cols-2 grid-cols-1 lg:items-center justify-between gap-x-4 flex-1 border-b border-light-35 dark:border-dark-35">
<input type="text" v-model="selected.title" @input="() => {
if(selected && !selected.customPath)
{
selected.name = parsePath(selected.title);
rebuildPath(selected.children, getPath(selected));
}
}" placeholder="Titre" style="line-height: normal;" class="flex-1 md:text-5xl text-4xl md:h-14 h-12 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 appearance-none outline-none pb-3 font-thin bg-transparent"/>
<div class="flex flex-row justify-between items-center gap-x-4">
<div v-if="selected.customPath" class="flex lg:items-center truncate">
<pre class="md:text-base text-sm truncate" style="direction: rtl">/{{ selected.parent !== '' ? selected.parent + '/' : '' }}</pre>
<TextInput v-model="selected.name" @input="(e: Event) => {
if(selected && selected.customPath)
{
selected.name = parsePath(selected.name);
rebuildPath(selected.children, getPath(selected));
}
}" class="mx-0 font-mono"/>
</div>
<pre v-else class="md:text-base text-sm truncate" style="direction: rtl">{{ getPath(selected) }}/</pre>
<div class="flex gap-4">
<Dialog :title="`Supprimer '${selected.title}'${selected.children?.length ?? 0 > 0 ? ' et ses enfants' : ''}`">
<template #trigger><Button icon class="bg-light-red dark:bg-dark-red !bg-opacity-40 border-light-red dark:border-dark-red hover:bg-light-red dark:hover:bg-dark-red hover:!bg-opacity-70 hover:border-light-red dark:hover:border-dark-red"><Icon icon="radix-icons:trash" /></Button></template>
<template #default>
<div class="flex gap-4">
<DialogClose><Button @click="navigation = tree.remove(navigation, getPath(selected)); selected = undefined;" class="bg-light-red dark:bg-dark-red !bg-opacity-40 border-light-red dark:border-dark-red hover:bg-light-red dark:hover:bg-dark-red hover:!bg-opacity-70 hover:border-light-red dark:hover:border-dark-red">Oui</Button></DialogClose>
<DialogClose><Button>Non</Button></DialogClose>
</div>
</template>
</Dialog>
<Dialog title="Préférences Markdown" v-if="selected.type === 'markdown'">
<template #trigger><Button icon><Icon icon="radix-icons:gear" /></Button></template>
<template #default>
<Select label="Editeur de markdown" :modelValue="preferences.markdown.editing" @update:model-value="v => preferences.markdown.editing = (v as 'reading' | 'editing' | 'split')">
<SelectItem label="Mode lecture" value="reading" />
<SelectItem label="Mode edition" value="editing" />
<SelectItem label="Ecran partagé" value="split" />
</Select>
</template>
</Dialog>
<DropdownMenu align="end" :options="[{
type: 'checkbox',
label: 'URL custom',
select: (state: boolean) => { selected!.customPath = state; if(!state) selected!.name = parsePath(selected!.title) },
checked: selected.customPath
}]">
<Button icon><Icon icon="radix-icons:dots-vertical"/></Button>
</DropdownMenu>
</div>
</div>
</div>
</CollapsibleContent>
</CollapsibleRoot>
<div class="py-4 flex-1 w-full max-h-full flex overflow-hidden xl:px-12 lg:px-8 px-6 relative">
<template v-if="selected.type === 'markdown'">
<div v-if="contentStatus === 'pending'" class="flex flex-1 justify-center items-center">
<Loading />
</div>
<span v-else-if="contentError">{{ contentError }}</span>
<template v-else-if="preferences.markdown.editing === 'editing'">
<Editor v-model="selected.content" autofocus class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto lg:mx-16 xl:mx-32 2xl:mx-64" />
</template>
<template v-else-if="preferences.markdown.editing === 'reading'">
<div class="flex-1 max-h-full !overflow-y-auto px-4 xl:px-32 2xl:px-64"><MarkdownRenderer :content="(debounced as string)" :proses="{ 'a': FakeA }" /></div>
</template>
<template v-else-if="preferences.markdown.editing === 'split'">
<SplitterGroup direction="horizontal" class="flex-1 w-full flex">
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }" :defaultSize="50">
<Editor v-model="selected.content" autofocus class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto" :class="{ 'hidden': isCollapsed }" />
</SplitterPanel>
<SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" />
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }">
<div class="flex-1 max-h-full !overflow-y-auto px-8" :class="{ 'hidden': isCollapsed }"><MarkdownRenderer :content="(debounced as string)" :proses="{ 'a': FakeA }" /></div>
</SplitterPanel>
</SplitterGroup>
</template>
</template>
<template v-else-if="selected.type === 'canvas'">
<CanvasEditor v-if="selected.content" :modelValue="selected.content" :path="getPath(selected)" />
</template>
<template v-else-if="selected.type === 'map'">
<span class="flex flex-1 justify-center items-center"><ProseH3>Editeur de carte en cours de développement</ProseH3></span>
</template>
<template v-else-if="selected.type === 'file'">
<span>Modifier le contenu :</span><input type="file" @change="(e: Event) => console.log((e.target as HTMLInputElement).files?.length)" />
</template>
</div>
</div>
</div>
<div class="flex flex-1 flex-col xl:-mx-12 xl:-my-8 lg:-mx-8 lg:-my-6 -mx-6 -my-3 overflow-hidden">
<div class="z-30 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
<div class="flex items-center px-2 gap-4">
<!-- <CollapsibleTrigger asChild>
<Button icon class="!bg-transparent group md:hidden">
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" />
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
</Button>
</CollapsibleTrigger> -->
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-opacity-70 m-2 flex items-center gap-4" aria-label="Accueil" :to="{ path: '/', force: true }">
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
<Avatar src="/logo.light.svg" class="block dark:hidden" />
<span class="text-xl max-md:hidden">d[any]</span>
</NuxtLink>
</div>
<div class="flex items-center px-2 gap-4">
<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 relative h-screen overflow-hidden">
<div class="bg-light-0 dark:bg-dark-0 z-40 w-screen md:w-[18rem] border-r border-light-30 dark:border-dark-30 flex flex-col justify-between my-2 max-md:data-[state=closed]:hidden">
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="tree"></div>
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60">
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
<p>Copyright Peaceultime - 2025</p>
</div>
</div>
</CollapsibleRoot>
</ClientOnly>
<div class="flex flex-1 flex-row max-h-full overflow-hidden" ref="container"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item';
import { iconByType, convertContentFromText, convertContentToText, DEFAULT_CONTENT,parsePath } from '#shared/general.util';
import type { ExploreContent, FileType, TreeItem } from '~/types/content';
import FakeA from '~/components/prose/FakeA.vue';
import type { Preferences } from '~/types/general';
import { Content, Editor } from '#shared/content.util';
import { button, loading } from '#shared/proses';
import { dom, icon, text } from '~/shared/dom.util';
import { modal, popper } from '~/shared/floating.util';
definePageMeta({
rights: ['admin', 'editor'],
layout: 'null',
});
export type TreeItemEditable = TreeItem &
const toaster = useToast();
const { user } = useUserSession();
const tree = useTemplateRef('tree'), container = useTemplateRef('container');
let editor: Editor;
function pull()
{
parent: string;
name: string;
customPath: boolean;
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 && await Content.ready)
{
const load = loading('normal');
tree.value.appendChild(load);
const content = dom('div', { class: 'flex flex-row justify-start items-center gap-4 p-2' }, [
popper(button(icon('ph:cloud-arrow-down', { height: 20, width: 20 }), pull, 'p-1'), { placement: 'top', offset: 4, delay: 120, arrow: true, content: [text('Actualiser')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
popper(button(icon('ph:cloud-arrow-up', { height: 20, width: 20 }), push, 'p-1'), { placement: 'top', offset: 4, delay: 120, arrow: true, content: [text('Enregistrer')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
])
tree.value.insertBefore(content, load);
editor = new Editor();
tree.value.replaceChild(editor.tree.container, load);
container.value.appendChild(editor.container);
}
});
onBeforeUnmount(() => {
editor?.unmount();
})
/* import { Icon } from '@iconify/vue/dist/iconify.js';
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item';
import type { FileType, LocalContent, TreeItem } from '#shared/content.util';
import { DEFAULT_CONTENT, iconByType, Content, getPath } from '#shared/content.util';
import type { Preferences } from '~/types/general';
import { fakeA as proseA } from '#shared/proses';
import { parsePath } from '~/shared/general.util';
import type { CanvasContent } from '~/types/canvas';
export type TreeItemEditable = LocalContent &
{
parent?: string;
name?: string;
customPath?: boolean;
children?: TreeItemEditable[];
}
@@ -240,225 +125,73 @@ const open = ref(true), topOpen = ref(true);
const toaster = useToast();
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
const { content: complete, tree: project } = useContent();
const navigation = ref<TreeItemEditable[]>(transform(JSON.parse(JSON.stringify(project.value)))!);
const selected = ref<TreeItemEditable>(), edited = ref(false);
const contentStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), contentError = ref<string>();
let navigation = Content.tree as TreeItemEditable[];
const selected = ref<TreeItemEditable>();
const preferences = useCookie<Preferences>('preferences', { default: () => ({ markdown: { editing: 'split' }, canvas: { snap: true, size: 32 } }), watch: true, maxAge: 60*60*24*31 });
const preferences = useCookie<Preferences>('preferences', { default: () => ({ markdown: { editing: 'split' }, canvas: { gridSnap: true, neighborSnap: true, spacing: 32 } }), watch: true, maxAge: 60*60*24*31 });
watch(selected, async (value, old) => {
if(selected.value)
{
if(!selected.value.content && selected.value.path)
{
contentStatus.value = 'pending';
try
{
const storedEdit = sessionStorage.getItem(`editing:${encodeURIComponent(selected.value.path)}`);
selected.value = await Content.content(selected.value.path);
}
if(storedEdit)
{
selected.value.content = convertContentFromText(selected.value.type, storedEdit);
contentStatus.value = 'success';
}
else
{
selected.value.content = (await $fetch(`/api/file/content/${encodeURIComponent(selected.value.path)}`, { query: { type: 'editing'} }));
contentStatus.value = 'success';
}
//@ts-ignore
debounced.value = selected.value.content ?? '';
}
catch(e)
{
contentError.value = (e as Error).message;
contentStatus.value = 'error';
}
}
else
{
//@ts-ignore
debounced.value = selected.value.content ?? '';
}
router.replace({ hash: '#' + selected.value.path || getPath(selected.value) });
router.replace({ hash: '#' + encodeURIComponent(selected.value!.path || getPath(selected.value!)) });
}
else
{
router.replace({ hash: '' });
}
})
const content = computed(() => selected.value?.content ?? '');
const debounced = useDebounce(content, 250, { maxWait: 500 });
const debouncedSave = useDebounceFn(save, 60000, { maxWait: 180000 });
watch(debounced, () => {
if(selected.value && debounced.value)
sessionStorage.setItem(`editing:${encodeURIComponent(selected.value.path)}`, typeof debounced.value === 'string' ? debounced.value : JSON.stringify(debounced.value));
});
useShortcuts({
meta_s: { usingInput: true, handler: () => save(false), prevent: true },
//meta_s: { usingInput: true, handler: () => save(), prevent: true },
meta_n: { usingInput: true, handler: () => add('markdown'), prevent: true },
meta_shift_n: { usingInput: true, handler: () => add('folder'), prevent: true },
meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore-path', params: { path: 'index' } }), prevent: true }
})
const tree = {
remove(data: TreeItemEditable[], id: string): TreeItemEditable[] {
return data
.filter(item => getPath(item) !== id)
.map((item) => {
if (tree.hasChildren(item)) {
return {
...item,
children: tree.remove(item.children ?? [], id),
};
}
return item;
});
},
insertBefore(data: TreeItemEditable[], targetId: string, newItem: TreeItemEditable): TreeItemEditable[] {
return data.flatMap((item) => {
if (getPath(item) === targetId)
return [newItem, item];
if (tree.hasChildren(item)) {
return {
...item,
children: tree.insertBefore(item.children ?? [], targetId, newItem),
};
}
return item;
});
},
insertAfter(data: TreeItemEditable[], targetId: string, newItem: TreeItemEditable): TreeItemEditable[] {
return data.flatMap((item) => {
if (getPath(item) === targetId)
return [item, newItem];
if (tree.hasChildren(item)) {
return {
...item,
children: tree.insertAfter(item.children ?? [], targetId, newItem),
};
}
return item;
});
},
insertChild(data: TreeItemEditable[], targetId: string, newItem: TreeItemEditable): TreeItemEditable[] {
return data.flatMap((item) => {
if (getPath(item) === targetId) {
// already a parent: add as first child
return {
...item,
// opening item so you can see where item landed
isOpen: true,
children: [newItem, ...item.children ?? []],
};
}
if (!tree.hasChildren(item))
return item;
return {
...item,
children: tree.insertChild(item.children ?? [], targetId, newItem),
};
});
},
find(data: TreeItemEditable[], itemId: string): TreeItemEditable | undefined {
for (const item of data) {
if (getPath(item) === itemId)
return item;
if (tree.hasChildren(item)) {
const result = tree.find(item.children ?? [], itemId);
if (result)
return result;
}
}
},
search(data: TreeItemEditable[], prop: keyof TreeItemEditable, value: string): TreeItemEditable[] {
const arr = [];
for (const item of data)
{
if (item[prop]?.toString().toLowerCase()?.startsWith(value.toLowerCase()))
arr.push(item);
if (tree.hasChildren(item)) {
arr.push(...tree.search(item.children ?? [], prop, value));
}
}
return arr;
},
getPathToItem({
current,
targetId,
parentIds = [],
}: {
current: TreeItemEditable[]
targetId: string
parentIds?: string[]
}): string[] | undefined {
for (const item of current) {
if (getPath(item) === targetId)
return parentIds;
const nested = tree.getPathToItem({
current: (item.children ?? []),
targetId,
parentIds: [...parentIds, getPath(item)],
});
if (nested)
return nested;
}
},
hasChildren(item: TreeItemEditable): boolean {
return (item.children ?? []).length > 0;
},
}
function add(type: FileType): void
{
if(!navigation.value)
if(!navigation)
{
return;
}
const news = [...tree.search(navigation.value, 'title', 'Nouveau')].filter((e, i, a) => a.indexOf(e) === i);
const news = [...tree.search(navigation, 'title', 'Nouveau')].filter((e, i, a) => a.indexOf(e) === i);
const title = `Nouveau${news.length > 0 ? ' (' + news.length +')' : ''}`;
const item: TreeItemEditable = { navigable: true, private: false, parent: '', path: '', title: title, name: parsePath(title), type: type, order: 0, children: [], customPath: false, content: DEFAULT_CONTENT[type], owner: -1, timestamp: new Date(), visit: 0 };
if(!selected.value)
{
navigation.value = [...navigation.value, item];
navigation = [...navigation, item];
}
else if(selected.value?.children)
{
item.parent = getPath(selected.value);
navigation.value = tree.insertChild(navigation.value, item.parent, item);
navigation = tree.insertChild(navigation, item.parent, item);
}
else
{
navigation.value = tree.insertAfter(navigation.value, getPath(selected.value), item);
navigation = tree.insertAfter(navigation, getPath(selected.value), item);
}
}
function updateTree(instruction: Instruction, itemId: string, targetId: string) : TreeItemEditable[] | undefined {
if(!navigation.value)
if(!navigation)
return;
const item = tree.find(navigation.value, itemId);
const target = tree.find(navigation.value, targetId);
const item = tree.find(navigation, itemId);
const target = tree.find(navigation, targetId);
if(!item)
return;
if (instruction.type === 'reparent') {
const path = tree.getPathToItem({
current: navigation.value,
current: navigation,
targetId: targetId,
});
if (!path) {
@@ -467,23 +200,23 @@ function updateTree(instruction: Instruction, itemId: string, targetId: string)
}
const desiredId = path[instruction.desiredLevel];
let result = tree.remove(navigation.value, itemId);
let result = tree.remove(navigation, itemId);
result = tree.insertAfter(result, desiredId, item);
return result;
}
// the rest of the actions require you to drop on something else
if (itemId === targetId)
return navigation.value;
return navigation;
if (instruction.type === 'reorder-above') {
let result = tree.remove(navigation.value, itemId);
let result = tree.remove(navigation, itemId);
result = tree.insertBefore(result, targetId, item);
return result;
}
if (instruction.type === 'reorder-below') {
let result = tree.remove(navigation.value, itemId);
let result = tree.remove(navigation, itemId);
result = tree.insertAfter(result, targetId, item);
return result;
}
@@ -492,13 +225,13 @@ function updateTree(instruction: Instruction, itemId: string, targetId: string)
if(!target || target.type !== 'folder')
return;
let result = tree.remove(navigation.value, itemId);
let result = tree.remove(navigation, itemId);
result = tree.insertChild(result, targetId, item);
rebuildPath([item], targetId);
return result;
}
return navigation.value;
return navigation;
}
function transform(items: TreeItem[] | undefined): TreeItemEditable[] | undefined
{
@@ -516,7 +249,7 @@ function flatten(items: TreeItemEditable[] | undefined): TreeItemEditable[]
}
function drop(instruction: Instruction, itemId: string, targetId: string)
{
navigation.value = updateTree(instruction, itemId, targetId) ?? navigation.value ?? [];
navigation = updateTree(instruction, itemId, targetId) ?? navigation ?? [];
}
function rebuildPath(tree: TreeItemEditable[] | null | undefined, parentPath: string)
{
@@ -528,38 +261,14 @@ function rebuildPath(tree: TreeItemEditable[] | null | undefined, parentPath: st
rebuildPath(e.children, getPath(e));
});
}
async function save(redirect: boolean): Promise<void>
function save()
{
//@ts-ignore
const map = (e: TreeItemEditable[]): TreeItemEditable[] => e.map(f => ({ ...f, content: f.content ? convertContentToText(f.type, f.content) : undefined, children: f.children ? map(f.children) : undefined }));
saveStatus.value = 'pending';
try {
const result = await $fetch(`/api/project`, {
method: 'post',
body: map(navigation.value),
});
saveStatus.value = 'success';
edited.value = false;
sessionStorage.clear();
toaster.clear('error');
toaster.add({ type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000 });
//@ts-ignore
complete.value = result as ExploreContent[];
if(redirect) router.go(-1);
} catch(e: any) {
toaster.add({
type: 'error', content: e.message, timer: true, duration: 10000
})
console.error(e);
saveStatus.value = 'error';
if(selected.value && selected.value.content)
{
selected.value.path = getPath(selected.value);
Content.save(selected.value);
}
}
function getPath(item: TreeItemEditable): string
{
return [item.parent, parsePath(item.customPath ? item.name : item.title)].filter(e => !!e).join('/');
}
const defaultExpanded = computed(() => {
if(router.currentRoute.value.hash)
@@ -568,11 +277,11 @@ const defaultExpanded = computed(() => {
split.forEach((e, i) => { if(i !== 0) split[i] = split[i - 1] + '/' + e });
return split;
}
})
/*watch(router.currentRoute, (value) => {
if(value && value.hash && navigation.value)
selected.value = tree.find(navigation.value, value.hash.substring(1));
});
watch(router.currentRoute, (value) => {
if(value && value.hash && navigation)
selected.value = tree.find(navigation, decodeURIComponent(value.hash.substring(1)));
else
selected.value = undefined;
}, { immediate: true });*/
}, { immediate: true }); */
</script>

View File

@@ -1,16 +0,0 @@
import { z } from "zod";
export const fileType = z.enum(['folder', 'file', 'markdown', 'canvas', 'map']);
export const schema = z.object({
path: z.string(),
owner: z.number().finite(),
title: z.string(),
type: fileType,
content: z.string(),
navigable: z.boolean(),
private: z.boolean(),
order: z.number().finite(),
});
export type FileType = z.infer<typeof fileType>;
export type File = z.infer<typeof schema>;

View File

@@ -1,16 +0,0 @@
import { z } from "zod";
import { fileType } from "./file";
export const single = z.object({
path: z.string(),
owner: z.number().finite(),
title: z.string(),
type: fileType,
navigable: z.boolean(),
private: z.boolean(),
order: z.number().finite(),
});
export const table = z.array(single);
export type Navigation = z.infer<typeof table>;
export type NavigationItem = z.infer<typeof single>;

View File

@@ -1,22 +1,16 @@
import { z } from "zod";
import { fileType } from "./file";
import { projectFilesTable } from "~/db/schema";
const baseItem = z.object({
export const Project = z.array(z.object({
id: z.string(),
path: z.string(),
parent: z.string(),
name: z.string(),
title: z.string(),
type: fileType,
type: z.enum(projectFilesTable.type.enumValues),
navigable: z.boolean(),
private: z.boolean(),
order: z.number().finite(),
content: z.string().optional().or(z.null()),
});
export const item: z.ZodType<ProjectItem> = baseItem.extend({
children: z.lazy(() => item.array().optional()),
});
export const project = z.array(item);
timestamp: z.string(),
}));
export type ProjectItem = z.infer<typeof baseItem> & {
children?: ProjectItem[]
};
export type ProjectType = z.infer<typeof Project>;
export type ProjectItemType = ProjectType[number];

View File

@@ -1,10 +1,10 @@
import type { SitemapUrlInput } from '#sitemap/types'
import { explorerContentTable } from '~/db/schema';
import { projectFilesTable as files } from '~/db/schema';
import useDatabase from '~/composables/useDatabase';
export default defineSitemapEventHandler(() => {
const db = useDatabase();
const pages = db.select({ path: explorerContentTable.path, lastMod: explorerContentTable.timestamp, navigable: explorerContentTable.navigable, private: explorerContentTable.private, type: explorerContentTable.type }).from(explorerContentTable).all();
const pages = db.select({ path: files.path, lastMod: files.timestamp, navigable: files.navigable, private: files.private, type: files.type }).from(files).all();
return pages.filter(e => e.type !== 'folder' && e.navigable && !e.private && e.path.split('/').map((_, i, a) => a.slice(0, i).join('/')).every(p => !pages.find(_p => _p.path === p)?.private)).map(e => ({
loc: `/explore/${encodeURIComponent(e.path)}`,

View File

@@ -1,7 +1,6 @@
import { ne, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { hasPermissions } from '~/shared/auth.util';
import { projectFilesTable } from '~/db/schema';
import { hasPermissions } from '#shared/auth.util';
export default defineEventHandler(async (e) => {
const session = await getUserSession(e);
@@ -16,17 +15,15 @@ export default defineEventHandler(async (e) => {
const db = useDatabase();
const content = db.select({
path: explorerContentTable.path,
owner: explorerContentTable.owner,
title: explorerContentTable.title,
type: explorerContentTable.type,
size: sql<number>`CASE WHEN ${explorerContentTable.content} IS NULL THEN 0 ELSE length(${explorerContentTable.content}) END`.as('size'),
navigable: explorerContentTable.navigable,
private: explorerContentTable.private,
order: explorerContentTable.order,
visit: explorerContentTable.visit,
timestamp: explorerContentTable.timestamp,
}).from(explorerContentTable).all();
path: projectFilesTable.path,
owner: projectFilesTable.owner,
title: projectFilesTable.title,
type: projectFilesTable.type,
navigable: projectFilesTable.navigable,
private: projectFilesTable.private,
order: projectFilesTable.order,
timestamp: projectFilesTable.timestamp,
}).from(projectFilesTable).all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;

View File

@@ -1,6 +1,4 @@
import { sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { userSessionsTable } from '~/db/schema';
import { hasPermissions } from '~/shared/auth.util';
export default defineEventHandler(async (e) => {

View File

@@ -93,8 +93,6 @@ export default defineEventHandler(async (e): Promise<Return> => {
}
}) as UserSessionRequired);
db.update(usersDataTable).set({ logCount: user.data.logCount + 1 }).where(eq(usersDataTable.id, user.id)).run();
setResponseStatus(e, 201);
return { success: true, session: data };
}

View File

@@ -5,7 +5,6 @@ import { usersDataTable, usersTable } from '~/db/schema';
import { schema } from '~/schemas/registration';
import { checkSession, logSession } from '~/server/utils/user';
import type { UserSession, UserSessionRequired } from '~/types/auth';
import sendMail from '~/server/tasks/mail';
interface SuccessHandler
{
@@ -72,7 +71,7 @@ export default defineEventHandler(async (e): Promise<Return> => {
db.insert(usersDataTable).values({ id: sql.placeholder('id') }).prepare().run({ id: id.id });
logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [], lastTimestamp: new Date(), logCount: 1 } }) as UserSessionRequired);
logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [], lastTimestamp: new Date() } }) as UserSessionRequired);
const emailId = Bun.hash('register' + id.id + hash, Date.now());
const timestamp = Date.now() + 1000 * 60 * 60;
@@ -83,7 +82,7 @@ export default defineEventHandler(async (e): Promise<Return> => {
id: emailId, timestamp,
}
});
await sendMail({
await runTask('mail', {
payload: {
type: 'mail',
to: [body.data.email],

View File

@@ -3,7 +3,6 @@ import { eq, or } from 'drizzle-orm';
import { z } from 'zod';
import useDatabase from '~/composables/useDatabase';
import { usersTable } from '~/db/schema';
import sendMail from '~/server/tasks/mail';
const schema = z.object({
profile: z.string(),
@@ -33,7 +32,7 @@ export default defineEventHandler(async (e) => {
id, timestamp,
}
});
await sendMail({
await runTask('mail', {
payload: {
type: 'mail',
data: {

View File

@@ -5,7 +5,6 @@ import { usersDataTable, usersTable } from '~/db/schema';
import { schema } from '~/schemas/registration';
import { checkSession, logSession } from '~/server/utils/user';
import type { UserSession, UserSessionRequired } from '~/types/auth';
import sendMail from '~/server/tasks/mail';
interface SuccessHandler
{
@@ -72,9 +71,9 @@ export default defineEventHandler(async (e): Promise<Return> => {
db.insert(usersDataTable).values({ id: sql.placeholder('id') }).prepare().run({ id: id.id });
logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [], lastTimestamp: new Date(), logCount: 1 } }) as UserSessionRequired);
logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [], lastTimestamp: new Date() } }) as UserSessionRequired);
await sendMail({
await runTask('mail', {
payload: {
type: 'mail',
to: [body.data.email],

View File

@@ -1,95 +0,0 @@
import { and, eq, SQL, sql, type Operators } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable, userPermissionsTable } from '~/db/schema';
import { hasPermissions } from '~/shared/auth.util';
import { group } from '~/shared/general.util';
import type { Character, DoubleIndex, Level, MainStat, TrainingLevel } from '~/types/character';
export default defineEventHandler(async (e) => {
let { visibility } = getQuery(e) as { visibility?: "public" | "own" | "admin" };
if(!visibility)
{
visibility = "own";
}
let where: ((character: typeof characterTable._.config.columns, sql: Operators) => SQL | undefined) | undefined = undefined;
const db = useDatabase();
if(visibility === "own")
{
const session = await getUserSession(e);
if(!session.user)
{
setResponseStatus(e, 401);
return;
}
where = (character, { eq, and }) => and(eq(character.owner, session.user!.id), eq(character.visibility, "private"));
}
else if(visibility === 'public')
{
where = (character, { eq, and }) => eq(character.visibility, "public");
}
else if(visibility === 'admin')
{
const session = await getUserSession(e);
if(!session.user)
{
setResponseStatus(e, 401);
return;
}
const db = useDatabase();
const rights = db.select({ right: userPermissionsTable.permission }).from(userPermissionsTable).where(eq(userPermissionsTable.id, session.user.id)).all();
if(rights.length === 0 || !hasPermissions(rights.map(e => e.right), ['admin']))
{
setResponseStatus(e, 403);
return;
}
where = undefined;
}
const characters = db.query.characterTable.findMany({
with: {
abilities: true,
levels: true,
modifiers: true,
spells: true,
training: true,
user: {
columns: { username: true }
}
},
where: where,
}).sync();
if(characters !== undefined)
{
return characters.map(character => ({
id: character.id,
name: character.name,
people: character.people,
level: character.level,
aspect: character.aspect,
notes: character.notes,
health: character.health,
mana: character.mana,
training: character.training.reduce((p, v) => { if(!(v.stat in p)) p[v.stat] = []; p[v.stat].push([v.level as TrainingLevel, v.choice]); return p; }, {} as Record<MainStat, DoubleIndex<TrainingLevel>[]>),
leveling: character.levels.map(e => [e.level as Level, e.choice] as DoubleIndex<Level>),
abilities: group(character.abilities.map(e => ({ ...e, value: [e.value, e.max] as [number, number] })), "ability", "value"),
spells: character.spells.map(e => e.value),
modifiers: group(character.modifiers, "modifier", "value"),
owner: character.owner,
username: character.user.username,
visibility: character.visibility,
} as Character));
}
setResponseStatus(e, 404);
return;
});

View File

@@ -1,66 +0,0 @@
import { z } from 'zod';
import useDatabase from '~/composables/useDatabase';
import { characterAbilitiesTable, characterLevelingTable, characterModifiersTable, characterSpellsTable, characterTable, characterTrainingTable } from '~/db/schema';
import { CharacterValidation, type Ability, type DoubleIndex, type MainStat, type TrainingLevel } from '~/types/character';
export default defineEventHandler(async (e) => {
const body = await readValidatedBody(e, CharacterValidation.extend({ id: z.unknown(), }).safeParse);
if(!body.success)
{
setResponseStatus(e, 400);
return body.error.message;
}
const session = await getUserSession(e);
if(!session.user || session.user.state !== 1)
{
setResponseStatus(e, 401);
return;
}
const db = useDatabase();
try
{
const id = db.transaction((tx) => {
const id = tx.insert(characterTable).values({
name: body.data.name,
owner: session.user!.id,
people: body.data.people!,
level: body.data.level,
aspect: body.data.aspect,
notes: body.data.notes,
health: body.data.health,
mana: body.data.mana,
visibility: body.data.visibility,
thumbnail: body.data.thumbnail,
}).returning({ id: characterTable.id }).get().id;
if(body.data.leveling.length > 0) tx.insert(characterLevelingTable).values(body.data.leveling.map(e => ({ character: id, level: e[0], choice: e[1] }))).run();
const training = Object.entries(body.data.training).flatMap(e => e[1].map(_e => ({ character: id, stat: e[0] as MainStat, level: _e[0], choice: _e[1] })));
if(training.length > 0) tx.insert(characterTrainingTable).values(training).run();
const modifiers = Object.entries(body.data.modifiers).map((e) => ({ character: id, modifier: e[0] as MainStat, value: e[1] }));
if(modifiers.length > 0) tx.insert(characterModifiersTable).values(modifiers).run();
if(body.data.spells.length > 0) tx.insert(characterSpellsTable).values(body.data.spells.map(e => ({ character: id, value: e }))).run();
const abilities = Object.entries(body.data.abilities).map(e => ({ character: id, ability: e[0] as Ability, value: e[1][0], max: e[1][1] }));
if(abilities.length > 0) tx.insert(characterAbilitiesTable).values(abilities).run();
return id;
});
setResponseStatus(e, 201);
return id;
}
catch(_e)
{
console.error(_e);
setResponseStatus(e, 500);
return;
}
});

View File

@@ -1,33 +0,0 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const db = useDatabase();
const old = db.select({ id: characterTable.id, owner: characterTable.owner }).from(characterTable).where(eq(characterTable.id, id)).get();
if(!old)
{
setResponseStatus(e, 404);
return;
}
const session = await getUserSession(e);
if(!session.user || old.owner !== session.user.id)
{
setResponseStatus(e, 401);
return;
}
db.delete(characterTable).where(eq(characterTable.id, id)).run();
setResponseStatus(e, 200);
return;
});

View File

@@ -1,66 +0,0 @@
import { and, eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema';
import { group } from '~/shared/general.util';
import type { Character, DoubleIndex, Level, MainStat, TrainingLevel } from '~/types/character';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const session = await getUserSession(e);
if(!session.user)
{
setResponseStatus(e, 401);
return;
}
const db = useDatabase();
const character = db.query.characterTable.findFirst({
with: {
abilities: true,
levels: true,
modifiers: true,
spells: true,
training: true,
user: {
columns: { username: true }
}
},
where: (character, { eq, and }) => and(eq(character.id, parseInt(id, 10)), eq(characterTable.owner, session.user!.id)),
}).sync();
if(character !== undefined)
{
return {
id: character.id,
name: character.name,
people: character.people,
level: character.level,
aspect: character.aspect,
notes: character.notes,
health: character.health,
mana: character.mana,
training: character.training.reduce((p, v) => { if(!(v.stat in p)) p[v.stat] = []; p[v.stat].push([v.level as TrainingLevel, v.choice]); return p; }, {} as Record<MainStat, DoubleIndex<TrainingLevel>[]>),
leveling: character.levels.map(e => [e.level as Level, e.choice] as DoubleIndex<Level>),
abilities: group(character.abilities.map(e => ({ ...e, value: [e.value, e.max] as [number, number] })), "ability", "value"),
spells: character.spells.map(e => e.value),
modifiers: group(character.modifiers, "modifier", "value"),
owner: character.owner,
username: character.user.username,
visibility: character.visibility,
} as Character;
}
setResponseStatus(e, 404);
return;
});

View File

@@ -1,75 +0,0 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterAbilitiesTable, characterLevelingTable, characterModifiersTable, characterSpellsTable, characterTable, characterTrainingTable } from '~/db/schema';
import { CharacterValidation, type Ability, type MainStat } from '~/types/character';
export default defineEventHandler(async (e) => {
const params = getRouterParam(e, "id");
if(!params)
{
setResponseStatus(e, 400);
return;
}
const id = parseInt(params, 10);
const body = await readValidatedBody(e, CharacterValidation.safeParse);
if(!body.success)
{
setResponseStatus(e, 400);
return body.error.message;
}
const db = useDatabase();
const old = db.select({ id: characterTable.id, owner: characterTable.owner }).from(characterTable).where(eq(characterTable.id, id)).get();
if(!old)
{
setResponseStatus(e, 404);
return;
}
const session = await getUserSession(e);
if(!session.user || old.owner !== session.user.id || session.user.state !== 1)
{
setResponseStatus(e, 401);
return;
}
db.transaction((tx) => {
tx.update(characterTable).set({
name: body.data.name,
people: body.data.people!,
level: body.data.level,
aspect: body.data.aspect,
notes: body.data.notes,
health: body.data.health,
mana: body.data.mana,
visibility: body.data.visibility,
thumbnail: body.data.thumbnail,
}).where(eq(characterTable.id, id)).run();
tx.delete(characterLevelingTable).where(eq(characterLevelingTable.character, id)).run();
tx.delete(characterTrainingTable).where(eq(characterTrainingTable.character, id)).run();
tx.delete(characterModifiersTable).where(eq(characterModifiersTable.character, id)).run();
tx.delete(characterSpellsTable).where(eq(characterSpellsTable.character, id)).run();
tx.delete(characterAbilitiesTable).where(eq(characterAbilitiesTable.character, id)).run();
if(body.data.leveling.length > 0) tx.insert(characterLevelingTable).values(body.data.leveling.map(e => ({ character: id, level: e[0], choice: e[1] }))).run();
const training = Object.entries(body.data.training).flatMap(e => e[1].map(_e => ({ character: id, stat: e[0] as MainStat, level: _e[0], choice: _e[1] })));
if(training.length > 0) tx.insert(characterTrainingTable).values(training).run();
const modifiers = Object.entries(body.data.modifiers).map((e) => ({ character: id, modifier: e[0] as MainStat, value: e[1] }));
if(modifiers.length > 0) tx.insert(characterModifiersTable).values(modifiers).run();
if(body.data.spells.length > 0) tx.insert(characterSpellsTable).values(body.data.spells.map(e => ({ character: id, value: e }))).run();
const abilities = Object.entries(body.data.abilities).map(e => ({ character: id, ability: e[0] as Ability, value: e[1][0], max: e[1][1] }));
if(abilities.length > 0) tx.insert(characterAbilitiesTable).values(abilities).run();
});
await useStorage('cache').removeItem(`nitro:functions:character:${id}.json`);
setResponseStatus(e, 200);
return;
});

View File

@@ -1,220 +0,0 @@
import useDatabase from '~/composables/useDatabase';
import { defaultCharacter, type Ability, type Character, type CharacterConfig, type CompiledCharacter, type DoubleIndex, type Feature, type Level, type MainStat, type TrainingLevel, type TrainingOption } from '~/types/character';
import characterData from '#shared/character-config.json';
import { group } from '~/shared/general.util';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const db = useDatabase();
const character = db.query.characterTable.findFirst({
with: {
abilities: true,
levels: true,
modifiers: true,
spells: true,
training: true,
user: {
columns: { username: true }
}
},
where: (character, { eq }) => eq(character.id, parseInt(id, 10)),
}).sync();
if(character !== undefined)
{
return compileCharacter(Object.assign(defaultCharacter, {
id: character.id,
name: character.name,
people: character.people,
level: character.level,
aspect: character.aspect,
notes: character.notes,
health: character.health,
mana: character.mana,
training: character.training.reduce((p, v) => { if(!(v.stat in p)) p[v.stat] = []; p[v.stat].push([v.level as TrainingLevel, v.choice]); return p; }, {} as Record<MainStat, DoubleIndex<TrainingLevel>[]>),
leveling: character.levels.map(e => [e.level as Level, e.choice] as DoubleIndex<Level>),
abilities: group(character.abilities.map(e => ({ ...e, value: [e.value, e.max] as [number, number] })), "ability", "value"),
spells: character.spells.map(e => e.value),
modifiers: group(character.modifiers, "modifier", "value"),
owner: character.owner,
username: character.user.username,
visibility: character.visibility,
} as Character) as Character);
}
setResponseStatus(e, 404);
return;
}/* , { name: "character", getKey: (e) => getRouterParam(e, "id") || 'error' } */);
function compileCharacter(character: Character & { username?: string }): CompiledCharacter
{
const config = characterData as CharacterConfig;
const race = character.people !== undefined ? config.peoples[character.people] : undefined;
const raceOptions = race ? character.leveling!.map(e => race.options[e[0]][e[1]]) : [];
const features = Object.entries(config.training).map(e => [e[0], getFeaturesOf(e[0] as MainStat, character.training[e[0] as MainStat])]) as [MainStat, TrainingOption[]][];
const compiled: CompiledCharacter = {
id: character.id,
owner: character.owner,
username: character.username,
name: character.name,
health: raceOptions.reduce((p, v) => p + (v.health ?? 0), 0),
mana: raceOptions.reduce((p, v) => p + (v.mana ?? 0), 0),
race: character.people!,
modifier: features.map(e => [e[0], Math.floor((e[1].length - 1) / 3) + (character.modifiers[e[0]] ?? 0)] as [MainStat, number]).reduce((p, v) => { p[v[0]] = v[1]; return p }, {} as Record<MainStat, number>),
level: character.level,
values: {
health: character.health,
mana: character.mana
},
features: {
action: [],
reaction: [],
freeaction: [],
misc: [],
},
abilities: {
athletics: 0,
acrobatics: 0,
intimidation: 0,
sleightofhand: 0,
stealth: 0,
survival: 0,
investigation: 0,
history: 0,
religion: 0,
arcana: 0,
understanding: 0,
perception: 0,
performance: 0,
medecine: 0,
persuasion: 0,
animalhandling: 0,
deception: 0
},
spellslots: 0,
artslots: 0,
spellranks: {
instinct: 0,
knowledge: 0,
precision: 0,
arts: 0,
},
spells: character.spells ?? [],
speed: false,
defense: {
static: 6,
activeparry: 0,
activedodge: 0,
passiveparry: 0,
passivedodge: 0,
},
mastery: {
strength: 0,
dexterity: 0,
shield: 0,
armor: 0,
multiattack: 1,
magicpower: 0,
magicspeed: 0,
magicelement: 0
},
resistance: {
stun: [0, 0],
bleed: [0, 0],
poison: [0, 0],
fear: [0, 0],
influence: [0, 0],
charm: [0, 0],
possesion: [0, 0],
precision: [0, 0],
knowledge: [0, 0],
instinct: [0, 0]
},
initiative: 0,
aspect: "",
notes: character.notes ?? "",
};
features.forEach(e => e[1].forEach((_e, i) => applyTrainingOption(e[0], _e, compiled, i === e[1].length - 1)));
specialFeatures(compiled, character.training);
Object.entries(character.abilities).forEach(e => compiled.abilities[e[0] as Ability]! += e[1][0]);
return compiled;
}
function applyTrainingOption(stat: MainStat, option: TrainingOption, character: CompiledCharacter, last: boolean)
{
if(option.health) character.health += option.health;
if(option.mana) character.mana += option.mana;
if(option.mastery) character.mastery[option.mastery]++;
if(option.speed) character.speed = option.speed;
if(option.initiative) character.initiative += option.initiative;
if(option.spellrank) character.spellranks[option.spellrank]++;
if(option.defense) option.defense.forEach(e => character.defense[e]++);
if(option.resistance) option.resistance.forEach(e => character.resistance[e[0]][e[1] === "attack" ? 0 : 1]++);
if(option.spellslot) character.spellslots += option.spellslot in character.modifier ? character.modifier[option.spellslot as MainStat] : option.spellslot as number;
if(option.arts) character.artslots += option.arts in character.modifier ? character.modifier[option.arts as MainStat] : option.arts as number;
if(option.spell) character.spells.push(option.spell);
option.description.forEach(line => !line.disposable && (last || !line.replaced) && character.features[line.category ?? "misc"].push(line.text));
//if(option.features) option.features.forEach(e => applyFeature(e, character));
}
function specialFeatures(character: CompiledCharacter, levels: Record<MainStat, DoubleIndex<TrainingLevel>[]>)
{
//Cap la défense
const strengthCap3 = levels.strength.some(e => e[0] === 0);
const strengthCap6 = levels.strength.some(e => e[0] === 1);
const strengthUncapped = levels.strength.some(e => e[0] === 2);
const dexterityCap3 = levels.dexterity.some(e => e[0] === 0);
const dexterityCap3Stat = levels.dexterity.some(e => e[0] === 1);
const dexterityUncapped = levels.dexterity.some(e => e[0] === 2);
if(!strengthUncapped || !dexterityUncapped)
{
if(strengthCap6)
{
character.defense = {
static: 6,
activeparry: 0,
activedodge: 0,
passiveparry: 0,
passivedodge: 0,
};
}
else if(strengthCap3 || dexterityCap3)
{
character.defense = {
static: 3,
activeparry: 0,
activedodge: 0,
passiveparry: 0,
passivedodge: 0,
};
}
else if(dexterityCap3Stat)
{
character.defense.static = 3;
}
}
}/*
function applyFeature(feature: Feature, character: CompiledCharacter)
{
} */
export function getFeaturesOf(stat: MainStat, progression: DoubleIndex<TrainingLevel>[]): TrainingOption[]
{
const config = characterData as CharacterConfig;
return progression.map(e => config.training[stat][e[0]][e[1]]);
}

View File

@@ -1,37 +0,0 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const db = useDatabase();
const old = db.select().from(characterTable).where(eq(characterTable.id, parseInt(id, 10))).get();
if(!old)
{
setResponseStatus(e, 404);
return;
}
const session = await getUserSession(e);
if(!session.user || old.owner !== session.user.id || session.user.state !== 1)
{
setResponseStatus(e, 401);
return;
}
const returned = await db.insert(characterTable).values({
name: `Copie de ${old.name}`,
progress: old.progress,
owner: session.user.id,
}).returning({ id: characterTable.id });
setResponseStatus(e, 201);
return returned[0].id;
});

View File

@@ -1,34 +0,0 @@
import { and, eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema';
import type { Character, CharacterValues } from '~/types/character';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const session = await getUserSession(e);
if(!session.user)
{
setResponseStatus(e, 401);
return;
}
const db = useDatabase();
const character = db.select({
values: characterTable.values
}).from(characterTable).where(and(eq(characterTable.id, parseInt(id, 10)), eq(characterTable.owner, session.user.id))).get();
if(character !== undefined)
{
return character.values as CharacterValues;
}
setResponseStatus(e, 404);
return;
});

View File

@@ -1,42 +0,0 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const body = await readBody(e);
if(!body)
{
setResponseStatus(e, 400);
return;
}
const db = useDatabase();
const old = db.select({ id: characterTable.id, owner: characterTable.owner }).from(characterTable).where(eq(characterTable.id, parseInt(id, 10))).get();
if(!old)
{
setResponseStatus(e, 404);
return;
}
const session = await getUserSession(e);
if(!session.user || old.owner !== session.user.id || session.user.state !== 1)
{
setResponseStatus(e, 401);
return;
}
db.update(characterTable).set({
values: body,
}).where(eq(characterTable.id, parseInt(id, 10))).run();
setResponseStatus(e, 200);
return;
});

View File

@@ -1,37 +0,0 @@
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { schema } from '~/schemas/file';
import { parsePath } from '~/shared/general.util';
export default defineEventHandler(async (e) => {
const body = await readValidatedBody(e, schema.safeParse);
if(!body.success)
{
setResponseStatus(e, 403);
throw body.error;
}
const db = useDatabase();
const buffer = Buffer.from(convertToStorableLinks(body.data.content, db.select({ path: explorerContentTable.path }).from(explorerContentTable).all().map(e => e.path)), 'utf-8');
const content = db.insert(explorerContentTable).values({ ...body.data, content: buffer }).onConflictDoUpdate({ target: explorerContentTable.path, set: { ...body.data, content: buffer, timestamp: new Date() } });
if(content !== undefined)
{
return content;
}
setResponseStatus(e, 404);
return;
});
export function convertToStorableLinks(content: string, path: string[]): string
{
return content.replaceAll(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (e: string, a1?: string, a2?: string , a3?: string) => {
const parsed = parsePath(a1 ?? '%%%%----%%%%----%%%%');
const replacer = path.find(e => e.endsWith(parsed));
const value = `[[${a1 ? (replacer ?? '') : ''}${a2 ?? ''}${(!a3 && a1 && replacer !== parsed ? '|' + a1 : a3) ?? ''}]]`;
return value;
});
}

View File

@@ -0,0 +1,46 @@
import { eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { projectFilesTable as files, projectContentTable as content } from '~/db/schema';
export default defineEventHandler(async (e) => {
try
{
const id = getRouterParam(e, "id") ?? '';
if(!id)
{
setResponseStatus(e, 404);
return;
}
const db = useDatabase();
const data = db.select({ content: sql<string>`cast(${content.content} as TEXT)`.as('content'), private: files.private, owner: files.owner, }).from(content).leftJoin(files, eq(content.id, files.id)).where(eq(content.id, id)).get();
if(data && data.content)
{
const session = await getUserSession(e);
if(!session || !session.user || session.user.id !== data.owner)
{
if(data.private)
{
setResponseStatus(e, 404);
return;
}
else
{
return data.content.replace(/%%(.+?)%%/g, "");
}
}
return data.content;
}
return;
}
catch(_e)
{
console.error(_e);
setResponseStatus(e, 500);
return;
}
});

View File

@@ -0,0 +1,43 @@
import useDatabase from '~/composables/useDatabase';
import { hasPermissions } from '#shared/auth.util';
import { projectContentTable, projectFilesTable } from '~/db/schema';
import { eq } from 'drizzle-orm';
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
if(!user || !hasPermissions(user.permissions, ['admin', 'editor']))
{
throw createError({ statusCode: 401, statusText: 'Unauthorized' });
}
try
{
const id = getRouterParam(e, "id") ?? '';
const body = await readRawBody(e);
if(!id)
{
setResponseStatus(e, 404);
return;
}
const db = useDatabase();
const item = db.select({ id: projectFilesTable.id }).from(projectFilesTable).where(eq(projectFilesTable.id, id)).get();
if(!item)
{
setResponseStatus(e, 404);
return;
}
db.insert(projectContentTable).values({ id: id, content: body }).onConflictDoUpdate({ set: { content: body }, target: projectContentTable.id }).run();
db.update(projectFilesTable).set({ timestamp: new Date() }).where(eq(projectFilesTable.id, id)).run()
}
catch(_e)
{
console.error(_e);
setResponseStatus(e, 500);
return;
}
});

View File

@@ -1,66 +0,0 @@
import { eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { convertContentFromText } from '~/shared/general.util';
export default defineEventHandler(async (e) => {
const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
const query = getQuery(e);
if(!path)
{
setResponseStatus(e, 404);
return;
}
const db = useDatabase();
const content = db.select({
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
'private': explorerContentTable.private,
'type': explorerContentTable.type,
'owner': explorerContentTable.owner,
'visit': explorerContentTable.visit,
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
if(content !== undefined)
{
const session = await getUserSession(e);
if(!session || !session.user || session.user.id !== content.owner)
{
if(content.private)
{
setResponseStatus(e, 404);
return;
}
else
{
content.content = content.content.replace(/%%(.+)%%/g, "");
}
}
if(query.type === 'view')
{
db.update(explorerContentTable).set({ visit: content.visit + 1 }).where(eq(explorerContentTable.path, path)).run();
}
if(query.type === 'editing')
{
content.content = convertFromStorableLinks(content.content);
}
return convertContentFromText(content.type, content.content);
}
setResponseStatus(e, 404);
return;
});
export function convertFromStorableLinks(content: string): string
{
/*return content.replaceAll(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (e: string, a1?: string, a2?: string , a3?: string) => {
const parsed = parsePath(a1 ?? '%%%%----%%%%----%%%%');
const replacer = path.find(e => e.endsWith(parsed)) ?? parsed;
const value = `[[${replacer}${a2 ?? ''}${(!a3 && replacer !== parsed ? '|' + a1 : a3) ?? ''}]]`;
return value;
});*/
return content;
}

View File

@@ -1,13 +1,11 @@
import useDatabase from '~/composables/useDatabase';
import { getTableColumns } from 'drizzle-orm';
import { explorerContentTable } from '~/db/schema';
import { projectFilesTable } from '~/db/schema';
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
const db = useDatabase();
const { content: _, ...columns } = getTableColumns(explorerContentTable);
const content = db.select(columns).from(explorerContentTable).all();
const content = db.select().from(projectFilesTable).all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;
@@ -36,6 +34,5 @@ export default defineEventHandler(async (e) => {
return content.filter(e => !!e);
}
setResponseStatus(e, 404);
return;
return [];
});

View File

@@ -0,0 +1,76 @@
import useDatabase from '~/composables/useDatabase';
import { hasPermissions } from "#shared/auth.util";
import { eq, sql } from "drizzle-orm";
import { projectFilesTable } from "~/db/schema";
import { Project } from "~/schemas/project";
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
if(!user || !hasPermissions(user.permissions, ['admin', 'editor']))
{
throw createError({ statusCode: 401, statusText: 'Unauthorized' });
}
const body = await readValidatedBody(e, Project.safeParse);
if(!body.success)
{
throw body.error;
}
const db = useDatabase(), items = body.data, blocked: string[] = [];
db.transaction((tx) => {
const data = tx.select({ id: projectFilesTable.id, timestamp: projectFilesTable.timestamp }).from(projectFilesTable).all();
const deletion = tx.delete(projectFilesTable).where(eq(projectFilesTable.id, sql.placeholder('id'))).prepare();
for(let i = 0; i < items.length; i++)
{
const item = items[i];
const index = data.findIndex(e => e.id === item.id);
if(index !== -1)
{
if(data[index].timestamp > new Date(item.timestamp))
{
blocked.push(item.id);
continue;
}
data.splice(index, 1);
}
tx.insert(projectFilesTable).values({
id: item.id,
path: item.path,
owner: user.id,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
}).onConflictDoUpdate({
set: {
id: item.id,
path: item.path,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
timestamp: new Date(),
},
target: projectFilesTable.id,
}).run();
}
for(let i = 0; i < data.length; i++)
{
deletion.run({ id: data[i].id });
}
});
return blocked;
});

View File

@@ -1,11 +1,11 @@
import { eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { projectFilesTable } from '~/db/schema';
export default defineEventHandler(async (e) => {
const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
export default defineCachedEventHandler(async (e) => {
const id = getRouterParam(e, "id") ?? '';
if(!path)
if(!id)
{
setResponseStatus(e, 404);
return;
@@ -14,13 +14,14 @@ export default defineEventHandler(async (e) => {
const db = useDatabase();
const content = db.select({
'path': explorerContentTable.path,
'owner': explorerContentTable.owner,
'title': explorerContentTable.title,
'type': explorerContentTable.type,
'navigable': explorerContentTable.navigable,
'private': explorerContentTable.private,
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
'id': projectFilesTable.id,
'path': projectFilesTable.path,
'owner': projectFilesTable.owner,
'title': projectFilesTable.title,
'type': projectFilesTable.type,
'navigable': projectFilesTable.navigable,
'private': projectFilesTable.private,
}).from(projectFilesTable).where(eq(projectFilesTable.id, sql.placeholder('id'))).prepare().get({ id });
if(content !== undefined)
{
@@ -47,4 +48,4 @@ export default defineEventHandler(async (e) => {
setResponseStatus(e, 404);
return;
});
}, { getKey: (e) => getRouterParam(e, "id") ?? '', });

View File

@@ -1,77 +0,0 @@
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import type { NavigationItem } from '~/schemas/navigation';
export type NavigationTreeItem = NavigationItem & { children?: NavigationTreeItem[] };
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
const db = useDatabase();
const content = db.select({
path: explorerContentTable.path,
type: explorerContentTable.type,
owner: explorerContentTable.owner,
title: explorerContentTable.title,
navigable: explorerContentTable.navigable,
private: explorerContentTable.private,
order: explorerContentTable.order,
}).from(explorerContentTable).all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;
});
if(content.length > 0)
{
const navigation: NavigationTreeItem[] = [];
for(const idx in content)
{
const item = content[idx];
if(!!item.private && (user?.id ?? -1) !== item.owner || !item.navigable)
{
delete content[idx];
continue;
}
const parent = item.path.includes('/') ? item.path.substring(0, item.path.lastIndexOf('/')) : undefined;
if(parent && !content.find(e => e && e.path === parent))
{
delete content[idx];
continue;
}
}
for(const item of content.filter(e => !!e))
{
addChild(navigation, item);
}
return navigation;
}
setResponseStatus(e, 404);
return;
});
function addChild(arr: NavigationTreeItem[], e: NavigationItem): void
{
const parent = arr.find(f => e.path.startsWith(f.path));
if(parent)
{
if(!parent.children)
parent.children = [];
addChild(parent.children, e);
}
else
{
arr.push({ ...e });
arr.sort((a, b) => {
if(a.order !== b.order)
return a.order - b.order;
return a.title.localeCompare(b.title);
});
}
}

View File

@@ -1,80 +0,0 @@
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import type { NavigationItem } from '~/schemas/navigation';
import type { ProjectItem, Project } from '~/schemas/project';
import { hasPermissions } from '~/shared/auth.util';
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
if(!user || !hasPermissions(user.permissions, ['editor', 'admin']))
{
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const db = useDatabase();
const content = db.select({
path: explorerContentTable.path,
type: explorerContentTable.type,
owner: explorerContentTable.owner,
title: explorerContentTable.title,
navigable: explorerContentTable.navigable,
private: explorerContentTable.private,
order: explorerContentTable.order,
}).from(explorerContentTable).prepare().all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;
});
if(content.length > 0)
{
const project: Project = {
items: [],
}
for(const item of content.filter(e => !!e))
{
addChild(project.items, item);
}
return project;
}
setResponseStatus(e, 404);
return;
});
function addChild(arr: ProjectItem[], e: NavigationItem): void
{
const parent = arr.find(f => e.path.startsWith(f.path));
if(parent)
{
if(!parent.children)
parent.children = [];
addChild(parent.children, e);
}
else
{
arr.push({
path: e.path,
parent: e.path.substring(0, e.path.lastIndexOf('/')),
name: e.path.substring(e.path.lastIndexOf('/') + 1),
title: e.title,
type: e.type,
navigable: e.navigable,
private: e.private,
order: e.order,
});
arr.sort((a, b) => {
if(a.order !== b.order)
return a.order - b.order;
return a.title.localeCompare(b.title);
});
}
}

View File

@@ -1,106 +0,0 @@
import { hasPermissions } from "#shared/auth.util";
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { project, type ProjectItem } from '~/schemas/project';
import { parsePath } from "#shared/general.util";
import { eq, getTableColumns, sql } from "drizzle-orm";
import type { ExploreContent, TreeItem } from "~/types/content";
import type { TreeItemEditable } from "~/pages/explore/edit/index.vue";
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
if(!user || !hasPermissions(user.permissions, ['admin', 'editor']))
{
throw createError({ statusCode: 401, statusText: 'Unauthorized' });
}
const body = await readValidatedBody(e, project.safeParse);
if(!body.success)
{
throw body.error;
}
const items = buildOrder(body.data) as Array<TreeItemEditable & { match?: number }>;
const db = useDatabase();
const { ...cols } = getTableColumns(explorerContentTable);
const full = db.select(cols).from(explorerContentTable).all() as Record<string, any>[];
for(let i = full.length - 1; i >= 0; i--)
{
const item = items.find(e => (e.path === '' ? [e.parent, parsePath(e.name === '' ? e.title : e.name)].filter(e => !!e).join('/') : e.path) === full[i].path);
if(item)
{
item.match = i;
full[i].include = true;
}
}
db.transaction((tx) => {
for(let i = 0; i < items.length; i++)
{
const item = items[i];
const old = full[item.match!];
const path = [item.parent, parsePath(item.name === '' ? item.title : item.name)].filter(e => !!e).join('/');
tx.insert(explorerContentTable).values({
path: item.path || path,
owner: user.id,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
content: item.content ?? old?.content ?? null,
}).onConflictDoUpdate({
set: {
path: path,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
timestamp: new Date(),
content: item.content ?? old?.content ?? null,
},
target: explorerContentTable.path,
}).run();
if(item.path !== path && !old)
{
tx.update(explorerContentTable).set({ content: sql`replace(${explorerContentTable.content}, ${sql.placeholder('old')}, ${sql.placeholder('new')})` }).prepare().run({ 'old': item.path, 'new': path });
}
}
for(let i = 0; i < full.length; i++)
{
if(full[i].include !== true)
tx.delete(explorerContentTable).where(eq(explorerContentTable.path, full[i].path)).run();
}
});
return items.map(e => ({
path: e.path,
owner: e.owner,
title: e.title,
type: e.type,
content: e.content,
navigable: e.navigable,
private: e.private,
order: e.order,
visit: e.visit,
timestamp: e.timestamp,
})) as ExploreContent[];
});
function buildOrder(items: ProjectItem[]): ProjectItem[]
{
items.forEach((e, i) => {
e.order = i;
if(e.children) e.children = buildOrder(e.children);
});
return items.flatMap(e => [e, ...(e.children ?? [])]);
}

View File

@@ -1,20 +0,0 @@
import useDatabase from '~/composables/useDatabase';
export default defineEventHandler(async (e) => {
const query = getQuery(e);
if (query.search) {
const db = useDatabase();
const files = db.query(`SELECT f.*, u.username, count(c.path) as comments FROM explorer_files f LEFT JOIN users u ON f.owner = u.id LEFT JOIN explorer_comments c ON c.project = f.project AND c.path = f.path WHERE title LIKE ?1 AND private = 0 AND type != "Folder" GROUP BY f.project, f.path`).all(query.search) as FileSearch[];
const users = db.query(`SELECT id, username FROM users WHERE username LIKE ?1`).all(query.search) as UserSearch[];
return {
projects,
files,
users
} as Search;
}
setResponseStatus(e, 404);
});

View File

@@ -2,7 +2,6 @@ import { hash } from "bun";
import { eq } from "drizzle-orm";
import useDatabase from "~/composables/useDatabase";
import { usersTable } from "~/db/schema";
import sendMail from '~/server/tasks/mail';
export default defineEventHandler(async (e) => {
const session = await getUserSession(e);
@@ -57,7 +56,7 @@ export default defineEventHandler(async (e) => {
id: emailId, timestamp,
}
});
await sendMail({
await runTask('mail', {
payload: {
type: 'mail',
to: [data.email],

View File

@@ -28,78 +28,69 @@ const transport = nodemailer.createTransport({
pool: true,
host: config.mail.host,
port: config.mail.port,
secure: config.mail.port === "465",
secure: true,
auth: {
user: config.mail.user,
pass: config.mail.passwd,
},
tls: { rejectUnauthorized: false },
requireTLS: true,
dkim: {
domainName: domain,
keySelector: selector,
privateKey: dkim,
},
proxy: config.mail.proxy,
});
if(process.env.NODE_ENV === 'production')
{
transport.verify((error) => {
if(error)
{
console.log('Mail server cannot be reached');
console.error(error);
export default defineTask({
meta: {
name: 'mail',
description: 'Send email',
},
async run(e) {
try {
if(e.payload.type !== 'mail')
{
throw new Error(`Données inconnues`);
}
const payload = e.payload as MailPayload;
const template = templates[payload.template];
if(!template)
{
throw new Error(`Modèle de mail ${payload.template} inconnu`);
}
console.time('Generating HTML');
const mail: Mail.Options = {
from: 'd[any] - Ne pas répondre <no-reply@peaceultime.com>',
to: payload.to,
html: await render(template.component, payload.data),
subject: template.subject,
textEncoding: 'quoted-printable',
};
console.timeEnd('Generating HTML');
if(mail.html === '')
return { result: false, error: new Error("Invalid content") };
console.time('Sending Mail');
const status = await transport.sendMail(mail);
console.timeEnd('Sending Mail');
if(status.rejected.length > 0)
{
return { result: false, error: status.response, details: status.rejectedErrors };
}
return { result: true };
}
else
console.log("Mail server is reachable and ready to communicate");
});
}
export default async function(e: TaskEvent) {
try {
if(e.payload.type !== 'mail')
catch(e)
{
throw new Error(`Données inconnues`);
return { result: false, error: e };
}
const payload = e.payload as MailPayload;
const template = templates[payload.template];
if(!template)
{
throw new Error(`Modèle de mail ${payload.template} inconnu`);
}
console.time('Generating HTML');
const mail: Mail.Options = {
from: 'd[any] - Ne pas répondre <no-reply@peaceultime.com>',
to: payload.to,
html: await render(template.component, payload.data),
subject: template.subject,
textEncoding: 'quoted-printable',
};
console.timeEnd('Generating HTML');
if(mail.html === '')
return { result: false, error: new Error("Invalid content") };
console.time('Sending Mail');
const status = await transport.sendMail(mail);
console.timeEnd('Sending Mail');
if(status.rejected.length > 0)
{
return { result: false, error: status.response, details: status.rejectedErrors };
}
return { result: true };
}
catch(e)
{
console.error(e);
return { result: false, error: e };
}
}
},
})
async function render(component: any, data: Record<string, any>): Promise<string>
{

View File

@@ -1,10 +1,9 @@
import useDatabase from "~/composables/useDatabase";
import { extname, basename } from 'node:path';
import type { FileType } from '~/types/content';
import type { CanvasColor, CanvasContent } from "~/types/canvas";
import { explorerContentTable } from "~/db/schema";
import { convertToStorableLinks } from "../api/file.post";
import { parsePath } from "~/shared/general.util";
import type { FileType, ProjectContent } from "#shared/content.util";
import { getID, ID_SIZE, parsePath } from "#shared/general.util";
import { projectContentTable, projectFilesTable } from "~/db/schema";
const typeMapping: Record<string, FileType> = {
".md": "markdown",
@@ -30,20 +29,22 @@ export default defineTask({
}
}) as { tree: any[] } & Record<string, any>;
const files: typeof explorerContentTable.$inferInsert[] = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e, i) => {
const files: ProjectContent[] = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e, i) => {
if(e.type === 'tree')
{
const title = basename(e.path);
const order = /(\d+)\. ?(.+)/gsmi.exec(title);
const path = (e.path as string).split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/');
return {
path: e.path,
id: getID(ID_SIZE),
path: parsePath(path),
order: i,
title: title,
title: order && order[2] ? order[2] : title,
type: 'folder',
content: null,
owner: 1,
navigable: true,
private: e.path === '98. Privé',
private: e.path.startsWith('98.Privé'),
timestamp: new Date(),
}
}
@@ -51,29 +52,32 @@ export default defineTask({
const extension = extname(e.path);
const title = basename(e.path, extension);
const order = /(\d+)\. ?(.+)/gsmi.exec(title);
const path = (e.path as string).split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/');
const content = (await $fetch(`https://git.peaceultime.com/api/v1/repos/peaceultime/system-aspect/raw/${encodeURIComponent(e.path)}`));
return {
path: extension === '.md' ? e.path.replace(extension, '') : e.path,
id: getID(ID_SIZE),
path: parsePath(extension === '.md' ? path.replace(extension, '') : path),
order: i,
title: title,
title: order && order[2] ? order[2] : title,
type: (typeMapping[extension] ?? 'file'),
content: reshapeContent(content as string, typeMapping[extension] ?? 'File'),
owner: 1,
navigable: true,
private: e.path === '98. Privé',
private: e.path.startsWith('98.Privé'),
timestamp: new Date(),
}
}));
files.forEach(e => {
const content = reshapeLinks(e.content as string | null, files) ?? null;
e.content = content ? Buffer.from(content, 'utf-8') : null;
});
files.forEach(e => e.content = reshapeLinks(e.content as string | null, files) ?? null);
const db = useDatabase();
db.delete(explorerContentTable).run();
db.insert(explorerContentTable).values(files).run();
db.transaction(tx => {
db.delete(projectFilesTable).run();
db.insert(projectFilesTable).values(files.map(e => {const { content, ...rest } = e; return rest; })).run();
db.delete(projectContentTable).run();
db.insert(projectContentTable).values(files.map(e => ({ content: e.content ? Buffer.from(e.content as string) : null, id: e.id }))).run();
});
return { result: true };
}
@@ -85,10 +89,11 @@ export default defineTask({
}
},
})
function reshapeLinks(content: string | null, all: typeof explorerContentTable.$inferInsert[])
function reshapeLinks(content: string | null, all: ProjectContent[])
{
return content?.replace(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (str, link, header, title) => {
return `[[${link ? all.find(e => e.path.endsWith(link))?.path ?? link : ''}${header ?? ''}${title ?? ''}]]`;
return content?.replace(/\[\[(.*?)?(#.*?)?(\|.*?)?\]\]/g, (str, link, header, title) => {
return `[[${link ? parsePath(all.find(e => e.path.endsWith(parsePath(link)))?.path ?? parsePath(link)) : ''}${header ?? ''}${title ?? ''}]]`;
});
}
@@ -97,6 +102,7 @@ function reshapeContent(content: string, type: FileType): string | null
switch(type)
{
case "markdown":
return content;
case "file":
return content;
case "canvas":

View File

@@ -1,12 +1,6 @@
import useDatabase from "~/composables/useDatabase";
import type { FileType } from '~/types/content';
import { explorerContentTable } from "~/db/schema";
import { eq, ne } from "drizzle-orm";
const typeMapping: Record<string, FileType> = {
".md": "markdown",
".canvas": "canvas"
};
import { projectFilesTable, projectContentTable } from "~/db/schema";
import { eq } from "drizzle-orm";
export default defineTask({
meta: {
@@ -15,19 +9,10 @@ export default defineTask({
},
async run(event) {
try {
const tree = await $fetch('https://git.peaceultime.com/api/v1/repos/peaceultime/system-aspect/git/trees/master', {
method: 'get',
headers: {
accept: 'application/json',
},
params: {
recursive: true,
per_page: 1000,
}
}) as any;
const db = useDatabase();
const files = db.select().from(explorerContentTable).where(ne(explorerContentTable.type, 'folder')).all();
const files = db.select().from(projectFilesTable).leftJoin(projectContentTable, eq(projectContentTable.id, projectFilesTable.id)).all();
return { result: true };
}

View File

@@ -14,7 +14,7 @@ export default defineTask({
name: 'validation',
description: 'Add email ID to DB',
},
run(e) {
async run(e) {
try {
if(e.payload.type !== 'validation')
{

View File

@@ -1,9 +1,17 @@
import type { CanvasNode } from "~/types/canvas";
import { clamp } from "#shared/general.util";
import type { CanvasContent, CanvasEdge, CanvasNode } from "~/types/canvas";
import { clamp, lerp } from "#shared/general.util";
import { dom, icon, svg, text } from "./dom.util";
import render from "./markdown.util";
import { popper } from "#shared/floating.util";
import { Content } from "./content.util";
import { History } from "./history.util";
import { fakeA, link } from "./proses";
import { SnapFinder, SpatialGrid } from "./physics.util";
import type { CanvasPreferences } from "~/types/general";
export type Direction = 'bottom' | 'top' | 'left' | 'right';
export type Position = { x: number, y: number };
export type Box = Position & { w: number, h: number };
export type Box = Position & { width: number, height: number };
export type Path = {
path: string;
from: Position;
@@ -100,4 +108,897 @@ export function getCenter(n: Position, i: Position, r: Position, o: Position, e:
export function gridSnap(value: number, grid: number): number
{
return Math.round(value / grid) * grid;
}
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);
}
export class Node extends EventTarget
{
properties: CanvasNode;
nodeDom!: HTMLDivElement;
constructor(properties: CanvasNode)
{
super();
this.properties = properties;
this.getDOM()
}
protected getDOM()
{
const style = this.style;
this.nodeDom = dom('div', { class: ['absolute', {'-z-10': this.properties.type === 'group', 'z-10': this.properties.type !== 'group'}], style: { transform: `translate(${this.properties.x}px, ${this.properties.y}px)`, width: `${this.properties.width}px`, height: `${this.properties.height}px`, '--canvas-color': this.properties.color?.hex } }, [
dom('div', { class: ['outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4', style.border] }, [
dom('div', { class: ['w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto', style.bg] }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text)]) : undefined])
])
]);
if(this.properties.type === 'group')
{
if(this.properties.label !== undefined)
{
this.nodeDom.appendChild(dom('div', { 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', style.border], style: 'max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))', text: this.properties.label }));
}
}
}
get style()
{
return this.properties.color ? this.properties.color?.class ?
{ bg: `bg-light-${this.properties.color?.class} dark:bg-dark-${this.properties.color?.class}`, border: `border-light-${this.properties.color?.class} dark:border-dark-${this.properties.color?.class}` } :
{ bg: `bg-colored`, border: `border-[color:var(--canvas-color)]` } :
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40` }
}
}
export class NodeEditable extends Node
{
private static input: HTMLInputElement = dom('input', { 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', style: { 'max-width': '100%', 'font-size': 'calc(18px * var(--zoom-multiplier))' }, listeners: { click: e => e.stopImmediatePropagation() } });
edges: Set<EdgeEditable> = new Set();
private dirty: boolean = false;
constructor(properties: CanvasNode)
{
super(properties);
}
protected override getDOM()
{
const style = this.style;
this.nodeDom = dom('div', { class: ['absolute group', {'-z-10': this.properties.type === 'group', 'z-10': this.properties.type !== 'group'}], style: { transform: `translate(${this.properties.x}px, ${this.properties.y}px)`, width: `${this.properties.width}px`, height: `${this.properties.height}px`, '--canvas-color': this.properties.color?.hex } }, [
dom('div', { class: ['outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full group-hover:outline-4', style.border, style.outline] }, [
dom('div', { class: ['w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto', style.bg], listeners: this.properties.type === 'text' ? { mouseenter: e => this.dispatchEvent(new CustomEvent('focus', { detail: this })), click: e => this.dispatchEvent(new CustomEvent('select', { detail: this })), dblclick: e => this.dispatchEvent(new CustomEvent('edit', { detail: this })) } : undefined }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text, undefined, { tags: { a: fakeA } })]) : undefined])
])
]);
if(this.properties.type === 'group')
{
if(this.properties.label !== undefined)
{
this.nodeDom.appendChild(dom('div', { 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', style.border], style: 'max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))', text: this.properties.label, listeners: { mouseenter: e => this.dispatchEvent(new CustomEvent('focus', { detail: this })), mouseleave: e => this.dispatchEvent(new CustomEvent('unfocus', { detail: this })), click: e => this.dispatchEvent(new CustomEvent('select', { detail: this })), dblclick: e => this.dispatchEvent(new CustomEvent('edit', { detail: this })) } }));
}
}
}
update()
{
if(!this.dirty)
return;
Object.assign(this.nodeDom.style, {
transform: `translate(${this.properties.x}px, ${this.properties.y}px)`,
width: `${this.properties.width}px`,
height: `${this.properties.height}px`,
});
this.edges.forEach(e => e.update());
this.dirty = false;
}
override get style()
{
return this.properties.color ? this.properties.color?.class ?
{ bg: `bg-light-${this.properties.color?.class} dark:bg-dark-${this.properties.color?.class}`, border: `border-light-${this.properties.color?.class} dark:border-dark-${this.properties.color?.class}`, outline: `outline-light-${this.properties.color?.class} dark:outline-dark-${this.properties.color?.class}` } :
{ bg: `bg-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` }
}
get x()
{
return this.properties.x;
}
set x(value: number)
{
this.properties.x = value;
this.dirty = true;
}
get y()
{
return this.properties.y;
}
set y(value: number)
{
this.properties.y = value;
this.dirty = true;
}
get width()
{
return this.properties.width;
}
set width(value: number)
{
this.properties.width = value;
this.dirty = true;
}
get height()
{
return this.properties.height;
}
set height(value: number)
{
this.properties.height = value;
this.dirty = true;
}
}
export class Edge extends EventTarget
{
properties: CanvasEdge;
edgeDom!: HTMLDivElement;
protected from: Node;
protected to: Node;
protected path: Path;
protected labelPos: string;
constructor(properties: CanvasEdge, from: Node, to: Node)
{
super();
this.properties = properties;
this.from = from;
this.to = to;
this.path = getPath(this.from.properties, properties.fromSide, this.to.properties, properties.toSide)!;
this.labelPos = labelCenter(this.from.properties, properties.fromSide, this.to.properties, properties.toSide);
this.getDOM();
}
protected getDOM()
{
const style = this.style;
this.edgeDom = dom('div', { class: 'absolute overflow-visible' }, [
this.properties.label ? dom('div', { style: { transform: `${this.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', text: this.properties.label }) : undefined,
svg('svg', { class: 'absolute top-0 overflow-visible h-px w-px' }, [
svg('g', { style: {'--canvas-color': this.properties.color?.hex}, class: 'z-0' }, [
svg('g', { style: `transform: translate(${this.path!.to.x}px, ${this.path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[this.path!.side]}deg);` }, [
svg('polygon', { class: style.fill, attributes: { points: '0,0 6.5,10.4 -6.5,10.4' } }),
]),
svg('path', { style: `stroke-width: calc(3px * var(--zoom-multiplier)); stroke-linecap: butt;`, class: [style.stroke, 'fill-none stroke-[4px]'], attributes: { d: this.path!.path } }),
]),
]),
]);
}
get style()
{
return this.properties.color ? this.properties.color?.class ?
{ fill: `fill-light-${this.properties.color?.class} dark:fill-dark-${this.properties.color?.class}`, stroke: `stroke-light-${this.properties.color?.class} dark:stroke-dark-${this.properties.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` }
}
}
export class EdgeEditable extends Edge
{
private static input: HTMLInputElement = dom('input', { class: 'relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20 -translate-x-1/2 -translate-y-1/2', listeners: { click: e => e.stopImmediatePropagation() } });
private focusing: boolean = false;
private editing: boolean = false;
private pathDom!: SVGPathElement;
private inputDom!: HTMLDivElement;
constructor(properties: CanvasEdge, from: NodeEditable, to: NodeEditable)
{
super(properties, from, to);
from.edges.add(this);
to.edges.add(this);
}
protected override getDOM()
{
const style = this.style;
this.pathDom = svg('path', { style: { 'stroke-width': `calc(3px * var(--zoom-multiplier))`, 'stroke-linecap': 'butt' }, class: ['transition-[stroke-width] fill-none stroke-[4px]', style.stroke], attributes: { d: this.path.path }, listeners: { mouseenter: e => this.dispatchEvent(new CustomEvent('focus', { detail: this })), click: e => this.dispatchEvent(new CustomEvent('select', { detail: this })), dblclick: e => this.dispatchEvent(new CustomEvent('edit', { detail: this })) } });
this.inputDom = dom('div', { class: ['relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20 -translate-x-1/2 -translate-y-1/2', { 'hidden': this.properties.label === undefined }], style: { transform: this.labelPos }, text: this.properties.label });
this.edgeDom = dom('div', { class: 'absolute overflow-visible group' }, [
this.inputDom,
svg('svg', { class: 'absolute top-0 overflow-visible h-px w-px' }, [
svg('g', { style: { '--canvas-color': this.properties.color?.hex } }, [
svg('g', { style: { transform: `translate(${this.path.to.x}px, ${this.path.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[this.path.side]}deg)` } }, [
svg('polygon', { class: style.fill, attributes: { 'points': '0,0 6.5,10.4 -6.5,10.4' } }),
]),
this.pathDom,
svg('path', { style: { 'stroke-width': `calc(22px * var(--zoom-multiplier))` }, class: ['fill-none transition-opacity z-30 opacity-0 hover:opacity-25', style.stroke], attributes: { d: this.path.path } }),
]),
]),
]);
}
update()
{
this.path = getPath(this.from.properties, this.properties.fromSide, this.to.properties, this.properties.toSide)!;
this.pathDom.setAttribute('d', this.path.path);
}
}
export class Canvas
{
static minZoom: number = 0.08;
static maxZoom: number = 3;
protected content: Required<CanvasContent>;
protected _zoom: number = 0.5;
protected _x: number = 0;
protected _y: number = 0;
protected containZoom: number = this._zoom;
protected centerX: number = this._x;
protected centerY: number = this._y;
protected visualZoom: number = this._zoom;
protected visualX: number = this._x;
protected visualY: number = this._y;
protected tweener: Tweener = new Tweener();
private debouncedTimeout: Timer = setTimeout(() => {}, 0);
protected transform!: HTMLDivElement;
container!: HTMLDivElement;
protected firstX = 0;
protected firstY = 0;
protected lastX = 0;
protected lastY = 0;
protected lastDistance = 0;
protected nodes: Node[] = [];
protected edges: Edge[] = [];
constructor(content?: CanvasContent)
{
if(!content)
content = { nodes: [], edges: [], groups: [] };
if(!content.nodes)
content.nodes = [];
if(!content.edges)
content.edges = [];
if(!content.groups)
content.groups = [];
this.content = content as Required<CanvasContent>;
this.createDOM();
}
protected createDOM()
{
this.nodes = this.content.nodes.map(e => new Node(e));
this.edges = this.content.edges.map(e => new Edge(e, this.nodes.find(f => e.fromNode === f.properties.id)!, this.nodes.find(f => e.toNode === f.properties.id)!));
//const { loggedIn, user } = useUserSession();
this.transform = dom('div', { class: 'origin-center h-full' }, [
dom('div', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none' }, [
dom('div', {}, this.nodes.map(e => e.nodeDom)), dom('div', {}, this.edges.map(e => e.edgeDom)),
])
]);
this.container = dom('div', { class: 'absolute top-0 left-0 overflow-hidden w-full h-full touch-none' }, [
dom('div', { class: 'flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4' }, [
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom * 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:plus')]), {
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom avant')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50'
}),
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: (e: MouseEvent) => { this.reset(); } } }, [icon('radix-icons:corners')]), {
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Tout contenir')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50'
}),
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom / 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:minus')]), {
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom arrière')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50'
}),
]),
//link({}, { name: 'explore-edit' }),
]), this.transform,
]);
}
protected computeLimits()
{
const box = this.container.getBoundingClientRect();
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.content.nodes.forEach(e => {
minX = Math.min(minX, e.x);
minY = Math.min(minY, e.y);
maxX = Math.max(maxX, e.x + e.width);
maxY = Math.max(maxY, e.y + e.height);
});
this.containZoom = clamp(Math.pow(1 / Math.max((maxX - minX) / box.width, (maxY - minY) / box.height), 1.05), Canvas.minZoom, 1);
this.centerX = -(minX + (maxX - minX) / 2) + box.width / 2;
this.centerY = -(minY + (maxY - minY) / 2) + box.height / 2;
}
mount()
{
const dragMove = (e: MouseEvent) => {
this.dragMove(e);
};
const dragEnd = (e: MouseEvent) => {
window.removeEventListener('mouseup', dragEnd);
window.removeEventListener('mousemove', dragMove);
this.dragEnd(e);
};
this.container.addEventListener('mouseenter', () => {
window.addEventListener('wheel', cancelEvent, { passive: false });
document.addEventListener('gesturestart', cancelEvent);
document.addEventListener('gesturechange', cancelEvent);
this.container.addEventListener('mouseleave', () => {
window.removeEventListener('wheel', cancelEvent);
document.removeEventListener('gesturestart', cancelEvent);
document.removeEventListener('gesturechange', cancelEvent);
});
});
this.container.addEventListener('mousedown', (e) => {
this.lastX = e.clientX;
this.lastY = e.clientY;
const pos = this.getPosFromCursor(e.clientX, e.clientY);
this.firstX = pos.x;
this.firstY = pos.y;
window.addEventListener('mouseup', dragEnd, { passive: true });
window.addEventListener('mousemove', dragMove, { passive: true });
this.dragStart(e);
}, { passive: true });
this.container.addEventListener('wheel', (e) => {
if((this._zoom >= Canvas.maxZoom && e.deltaY < 0) || (this._zoom <= this.containZoom && e.deltaY > 0))
return;
const box = this.container.getBoundingClientRect(), 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;
this.zoomTo(this._x - (mousex / (diff * this._zoom) - mousex / this._zoom), this._y - (mousey / (diff * this._zoom) - mousey / this._zoom), clamp(this._zoom * diff, this.containZoom, Canvas.maxZoom));
}, { passive: true });
this.container.addEventListener('touchstart', (e) => {
({ x: this.lastX, y: this.lastY } = center(e.touches));
if(e.touches.length > 1)
{
this.lastDistance = distance(e.touches);
}
this.container.addEventListener('touchend', touchend, { passive: true });
this.container.addEventListener('touchcancel', touchcancel, { passive: true });
this.container.addEventListener('touchmove', touchmove, { passive: true });
}, { passive: true });
const touchend = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: this.lastX, y: this.lastY } = center(e.touches));
}
this.container.removeEventListener('touchend', touchend);
this.container.removeEventListener('touchcancel', touchcancel);
this.container.removeEventListener('touchmove', touchmove);
};
const touchcancel = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: this.lastX, y: this.lastY } = center(e.touches));
}
this.container.removeEventListener('touchend', touchend);
this.container.removeEventListener('touchcancel', touchcancel);
this.container.removeEventListener('touchmove', touchmove);
};
const touchmove = (e: TouchEvent) => {
const pos = center(e.touches);
this._x = this.visualX = this._x - (this.lastX - pos.x) / this._zoom;
this._y = this.visualY = this._y - (this.lastY - pos.y) / this._zoom;
this.lastX = pos.x;
this.lastY = pos.y;
if(e.touches.length === 2)
{
const dist = distance(e.touches);
const diff = dist / this.lastDistance;
this._zoom = clamp(this._zoom * diff, this.containZoom, Canvas.maxZoom);
}
this.updateTransform();
};
this.computeLimits();
this.reset();
}
protected updateTransform()
{
this.transform.style.transform = `scale3d(${this.visualZoom}, ${this.visualZoom}, 1) translate3d(${this.visualX}px, ${this.visualY}px, 0)`;
clearTimeout(this.debouncedTimeout);
this.debouncedTimeout = setTimeout(this.updateScale.bind(this), 150);
}
private updateScale()
{
this.transform.style.setProperty('--tw-scale', this.visualZoom.toString());
this.container.style.setProperty('--zoom-multiplier', (1 / Math.pow(this.visualZoom, 0.7)).toFixed(3));
}
protected zoomTo(x: number, y: number, zoom: number)
{
const oldX = this._x, oldY = this._y, oldZoom = this._zoom;
this._x = x;
this._y = y;
this._zoom = zoom;
this.tweener.update((e) => {
this.visualX = lerp(e, oldX, x);
this.visualY = lerp(e, oldY, y);
this.visualZoom = lerp(e, oldZoom, zoom);
this.updateTransform();
}, 50);
}
protected reset()
{
this.zoomTo(this.centerX, this.centerY, this.containZoom);
}
protected dragStart(e: MouseEvent) {}
protected dragMove(e: MouseEvent)
{
this._x = this.visualX = this._x - (this.lastX - e.clientX) / this._zoom;
this._y = this.visualY = this._y - (this.lastY - e.clientY) / this._zoom;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.updateTransform();
}
protected dragEnd(e: MouseEvent) {}
protected getPosFromCursor(x: number, y: number): Position
{
const box = this.container.getBoundingClientRect();
const centerX = box.x + box.width / 2, centerY = box.y + box.height / 2;
return {x: (x - centerX) / this._zoom - this._x + box.width / 2, y: (y - centerY) / this._zoom - this._y + box.height / 2 };
}
get zoom()
{
return this._zoom;
}
get x()
{
return this._x;
}
get y()
{
return this._y;
}
get viewport()
{
const box = this.container.getBoundingClientRect();
const width = box.width / this._zoom, height = box.height / this._zoom;
const movementX = box.width - width, movementY = box.height - height;
return { x: -this._x + movementX / 2, y: -this._y + movementY / 2, width, height };
}
}
export class CanvasEditor extends Canvas
{
private static SPACING = 32;
private history: History;
private focused: NodeEditable | EdgeEditable | undefined;
private selection: Set<NodeEditable> = new Set();
private dragging: boolean = false;
private dragger: HTMLElement = dom('div', { class: 'border border-accent-blue absolute shadow-accent-blue pointer-events-none', style: { 'box-shadow': '0 0 2px var(--tw-shadow-color)' } });
private pattern: SVGElement = svg('svg', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none' }, [
svg('pattern', { attributes: { id: 'canvasPattern', patternUnits: 'userSpaceOnUse' } }, [ svg('circle', { class: 'fill-light-35 dark:fill-dark-35', attributes: { cx: '0.75', cy: '0.75', r: '0.75' } }) ]),
svg('rect', { attributes: { x: '0', y: '0', width: '100%', height: '100%', fill: 'url(#canvasPattern)' } })
]);
private nodeHelper: HTMLElement = dom('div', { class: 'cursor-move absolute z-40', listeners: { mousedown: e => this.moveSelection(e) }, style: { width: '0px', height: '0px' } }, [
dom('span', { class: 'cursor-n-resize absolute -top-3 -right-3 -left-3 h-6 group', listeners: { mousedown: e => this.resizeSelection(e, 0, 1, 0, -1) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 top-1 left-1/2 -translate-x-2', listeners: { mousedown: e => this.dragNewEdge(e, 'top') } }) ]),
dom('span', { class: 'cursor-s-resize absolute -bottom-3 -right-3 -left-3 h-6 group', listeners: { mousedown: e => this.resizeSelection(e, 0, 0, 0, 1) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 bottom-1 left-1/2 -translate-x-2', listeners: { mousedown: e => this.dragNewEdge(e, 'bottom') } }) ]),
dom('span', { class: 'cursor-e-resize absolute -top-3 -bottom-3 -right-3 w-6 group', listeners: { mousedown: e => this.resizeSelection(e, 0, 0, 1, 0) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 right-1 top-1/2 -translate-y-2', listeners: { mousedown: e => this.dragNewEdge(e, 'right') } }) ]),
dom('span', { class: 'cursor-w-resize absolute -top-3 -bottom-3 -left-3 w-6 group', listeners: { mousedown: e => this.resizeSelection(e, 1, 0, -1, 0) } }, [ dom('span', { class: 'hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-4 h-4 left-1 top-1/2 -translate-y-2', listeners: { mousedown: e => this.dragNewEdge(e, 'left') } }) ]),
dom('span', { class: 'cursor-nw-resize absolute -top-4 -left-4 w-8 h-8', listeners: { mousedown: e => this.resizeSelection(e, 1, 1, -1, -1) } }),
dom('span', { class: 'cursor-ne-resize absolute -top-4 -right-4 w-8 h-8', listeners: { mousedown: e => this.resizeSelection(e, 0, 1, 1, -1) } }),
dom('span', { class: 'cursor-se-resize absolute -bottom-4 -right-4 w-8 h-8', listeners: { mousedown: e => this.resizeSelection(e, 0, 0, 1, 1) } }),
dom('span', { class: 'cursor-sw-resize absolute -bottom-4 -left-4 w-8 h-8', listeners: { mousedown: e => this.resizeSelection(e, 1, 0, -1, 1) } }),
]);
private edgeHelper: HTMLElement = dom('div', { class: 'absolute', listeners: { } });
private boxHelper: HTMLElement = dom('div', { class: '-m-2 border border-accent-purple absolute z-10 p-2 box-content', listeners: { mouseenter: () => this.focusSelection() } });
protected override nodes: NodeEditable[] = [];
protected override edges: EdgeEditable[] = [];
private preferences: Ref<CanvasPreferences>;
private grid: SpatialGrid<NodeEditable> = new SpatialGrid<NodeEditable>(128);
constructor(content?: CanvasContent)
{
super(content);
this.createDOM();
this.history = new History();
this.history.register('canvas', {
move: {
undo: action => {
},
redo: action => {
},
}
});
this.preferences = useCookie<CanvasPreferences>('canvasPreference', { default: () => ({ gridSnap: true, neighborSnap: true, spacing: 32 }) });
}
protected override createDOM()
{
if(!this.grid)
return;
this.nodes = this.content.nodes.map(e => {
const node = new NodeEditable(e);
//@ts-ignore
node.properties.type === "text" && node.addEventListener('focus', this.focusNode.bind(this));
//@ts-ignore
node.addEventListener('select', this.selectNode.bind(this));
//@ts-ignore
node.addEventListener('edit', this.editNode.bind(this));
node.properties.type === "text" && this.grid.insert(node);
return node;
});
this.edges = this.content.edges.map(e => {
const edge = new EdgeEditable(e, this.nodes.find(f => e.fromNode === f.properties.id)!, this.nodes.find(f => e.toNode === f.properties.id)!);
//@ts-ignore
edge.addEventListener('focus', this.focusEdge.bind(this));
//@ts-ignore
edge.addEventListener('select', this.selectEdge.bind(this));
//@ts-ignore
edge.addEventListener('edit', this.editEdge.bind(this));
return edge;
});
this.transform = dom('div', { class: 'origin-center h-full' }, [
dom('div', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none' }, [
dom('div', {}, [...this.nodes.map(e => e.nodeDom), this.nodeHelper]), dom('div', {}, [...this.edges.map(e => e.edgeDom)]),
]), this.edgeHelper,
]);
this.container = dom('div', { class: 'absolute top-0 left-0 overflow-hidden w-full h-full touch-none', listeners: { mousedown: () => { this.selection.clear(); this.updateSelection() } } }, [
dom('div', { class: 'flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4' }, [
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom * 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:plus')]), {
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom avant')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50'
}),
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.reset() } }, [icon('radix-icons:corners')]), {
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Tout contenir')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50'
}),
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this._x, this._y, clamp(this._zoom / 1.1, this.containZoom, Canvas.maxZoom)) } }, [icon('radix-icons:minus')]), {
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom arrière')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50'
}),
]),
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.history.undo() } }, [icon('ph:arrow-bend-up-left')]), {
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Annuler (Ctrl+Z)')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50'
}),
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.history.redo() } }, [icon('ph:arrow-bend-up-right')]), {
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Rétablir (Ctrl+Y)')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50'
}),
]),
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => {} } }, [icon('radix-icons:gear')]), {
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Préférences')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50'
}),
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => {} } }, [icon('radix-icons:question-mark-circled')]), {
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Aide')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50'
}),
]),
]), this.pattern, this.transform
]);
}
private focusNode(e: CustomEvent<NodeEditable>)
{
if(this.dragging)
return;
e.stopImmediatePropagation();
this.focused = e.detail;
Object.assign(this.nodeHelper.style, {
transform: `translate(${e.detail.x}px, ${e.detail.y}px)`,
width: `${e.detail.width}px`,
height: `${e.detail.height}px`,
});
}
private selectNode(e: CustomEvent<NodeEditable>)
{
}
private editNode(e: CustomEvent<NodeEditable>)
{
}
private updateSelection()
{
if(this.selection.size > 0)
{
const selectionBox = getBox(this.selection);
Object.assign(this.boxHelper.style, {
left: `${selectionBox.x}px`,
top: `${selectionBox.y}px`,
width: `${selectionBox.width}px`,
height: `${selectionBox.height}px`,
});
if(this.boxHelper.parentElement === null) this.transform.appendChild(this.boxHelper);
}
else
this.boxHelper.remove();
}
private focusSelection()
{
if(this.dragging)
return;
const box = this.boxHelper.getBoundingClientRect();
Object.assign(this.nodeHelper.style, {
top: `${box.top}px`,
left: `${box.left}px`,
width: `${box.width}px`,
height: `${box.height}px`,
});
}
private moveSelection(e: MouseEvent)
{
if(!(e.buttons & 1))
return;
e.stopImmediatePropagation();
if(this.selection.size === 0 && this.focused !== undefined)
{
this.selection.add(this.focused as NodeEditable);
this.updateSelection();
}
const end = () => {
if(moveX !== 0 && moveY !== 0)
this.history.add('canvas', 'move', [...this.selection.values()].map(e => ({ element: e, from: { x: startX, y: startY }, to: { x: startX + moveX, y: startY + moveY } })));
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', end);
};
const move = (e: MouseEvent) => {
const movementX = e.movementX / this._zoom, movementY = e.movementY / this._zoom;
moveX += movementX;
moveY += movementY;
this.selection.forEach(_e => {
_e.x += movementX;
_e.y += movementY;
_e.update();
});
let box = this.boxHelper.getBoundingClientRect();
Object.assign(this.boxHelper.style, {
left: `${box.x + movementX}px`,
top: `${box.y + movementY}px`,
})
box = this.nodeHelper.getBoundingClientRect();
Object.assign(this.nodeHelper.style, {
left: `${box.x + movementX}px`,
top: `${box.y + movementY}px`,
})
};
const startX = e.clientX, startY = e.clientY;
let moveX = 0, moveY = 0;
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', end);
}
private resizeSelection(e: MouseEvent, x: number, y: number, w: number, h: number)
{
if(!(e.buttons & 1))
return;
e.stopImmediatePropagation();
}
private focusEdge(e: CustomEvent<EdgeEditable>)
{
this.focused = e.detail;
}
private selectEdge(e: CustomEvent<EdgeEditable>)
{
}
private editEdge(e: CustomEvent<EdgeEditable>)
{
}
private dragNewEdge(e: MouseEvent, direction: Direction)
{
e.stopImmediatePropagation();
}
override updateTransform()
{
super.updateTransform();
this.pattern.parentElement?.classList.toggle('hidden', !this.preferences.value.gridSnap);
if(this.preferences.value.gridSnap)
{
this.pattern.setAttribute("x", (this.viewport.width / 2 + this._x % CanvasEditor.SPACING * this._zoom).toFixed(3));
this.pattern.setAttribute("y", (this.viewport.height / 2 + this._y % CanvasEditor.SPACING * this._zoom).toFixed(3));
this.pattern.setAttribute("width", (this._zoom * CanvasEditor.SPACING).toFixed(3));
this.pattern.setAttribute("height", (this._zoom * CanvasEditor.SPACING).toFixed(3));
this.pattern.children[0].setAttribute('cx', (this._zoom).toFixed(3));
this.pattern.children[0].setAttribute('cy', (this._zoom).toFixed(3));
this.pattern.children[0].setAttribute('r', (this._zoom).toFixed(3));
}
}
override mount()
{
super.mount();
this.container.addEventListener('contextmenu', cancelEvent);
}
protected override dragStart(e: MouseEvent)
{
super.dragStart(e);
this.dragging = !!(e.buttons & 1);
if(this.dragging)
{
this.transform.appendChild(this.dragger);
const pos = this.getPosFromCursor(e.clientX, e.clientY);
Object.assign(this.dragger.style, {
left: `${pos.x}px`,
top: `${pos.y}px`,
width: `0px`,
height: `0px`,
});
}
}
protected override dragMove(e: MouseEvent)
{
if(this.dragging)
{
const pos = this.getPosFromCursor(e.clientX, e.clientY);
const minX = Math.min(this.firstX, pos.x), minY = Math.min(this.firstY, pos.y), maxX = Math.max(this.firstX, pos.x), maxY = Math.max(this.firstY, pos.y);
Object.assign(this.dragger.style, {
left: `${minX}px`,
top: `${minY}px`,
width: `${maxX - minX}px`,
height: `${maxY - minY}px`,
});
this.selection = new Set(this.grid.query(minX, minY, maxX, maxY));
this.updateSelection();
}
else if(!this.dragging)
{
super.dragMove(e);
}
}
protected override dragEnd(e: MouseEvent)
{
this.dragging = false;
this.dragger.remove();
}
}
function getBox<T extends Box>(selection: Set<T>): Box
{
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
selection.forEach(e => {
minX = Math.min(minX, e.x);
minY = Math.min(minY, e.y);
maxX = Math.max(maxX, e.x + e.width);
maxY = Math.max(maxY, e.y + e.height);
});
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
class Tweener
{
static linear = (progress: number) => progress;
private progress: number;
private duration: number;
private last: number;
private animationFrame: number = 0;
private animation: (progress: number) => number;
private tick?: (progress: number) => void;
constructor(animation: (progress: number) => number = Tweener.linear)
{
this.progress = 0, this.duration = 0, this.last = 0;
this.animation = animation;
}
private loop(t: DOMHighResTimeStamp)
{
const elapsed = t - this.last;
this.progress = clamp(this.progress + elapsed, 0, this.duration);
this.last = t;
const step = this.animation(clamp(this.progress / this.duration, 0, 1));
this.tick!(step);
if(this.progress < this.duration)
this.animationFrame = requestAnimationFrame(this.loop.bind(this));
}
update(tick: (progress: number) => void, duration: number)
{
this.duration = duration + this.duration - this.progress;
this.progress = 0;
this.last = performance.now();
this.tick = tick;
cancelAnimationFrame(this.animationFrame);
this.animationFrame = requestAnimationFrame(this.loop.bind(this));
}
stop()
{
cancelAnimationFrame(this.animationFrame);
this.duration = 0;
this.progress = 0;
}
}

File diff suppressed because it is too large Load Diff

902
shared/content.util.ts Normal file
View File

@@ -0,0 +1,902 @@
import { safeDestr as parse } from 'destr';
import { Canvas, CanvasEditor } from "#shared/canvas.util";
import render from "#shared/markdown.util";
import { confirm, contextmenu, popper } from "#shared/floating.util";
import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util";
import prose, { h1, h2, loading } from "#shared/proses";
import { getID, ID_SIZE, parsePath } from '#shared/general.util';
import { TreeDOM, type Recursive } from '#shared/tree';
import { History } from '#shared/history.util';
import { MarkdownEditor } from '#shared/editor.util';
import type { CanvasContent } from '~/types/canvas';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
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 { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import type { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';
export type FileType = keyof ContentMap;
export interface ContentMap
{
markdown: string;
file: string;
canvas: CanvasContent;
map: string;
folder: null;
}
export interface Overview<T extends FileType>
{
id: string;
path: string;
owner: number;
title: string;
timestamp: Date;
navigable: boolean;
private: boolean;
order: number;
type: T;
}
export type ProjectContent<T extends FileType = FileType> = Overview<T> & { content: ContentMap[T] };
class AsyncQueue
{
private size: number;
private count: number = 0;
private _queue: Array<() => Promise<any>>;
promise: Promise<void> = Promise.resolve();
finished: boolean = true;
private res: (value: void | PromiseLike<void>) => void = () => {};
private rej: (value: void | PromiseLike<void>) => void = () => {};
constructor(size: number = 8)
{
this.size = size;
this._queue = [];
}
queue(fn: () => Promise<any>): Promise<void>
{
if(this.finished)
{
this.finished = false;
this.promise = new Promise((res, rej) => {
this.res = res;
this.rej = rej;
});
}
this._queue.push(fn);
this.refresh();
return this.promise;
}
private refresh()
{
for(let i = this.count; i < this.size && this._queue.length > 0; i++)
{
this.count++;
const fn = this._queue.shift()!;
fn().catch(e => this.rej(e)).then(() => {
this.count--;
this.refresh();
});
}
if(this.count === 0 && this._queue.length === 0 && !this.finished)
{
this.finished = true;
this.res();
}
}
}
export const DEFAULT_CONTENT: Record<FileType, ContentMap[FileType]> = {
map: {},
canvas: { nodes: [], edges: []},
markdown: '',
file: '',
folder: null,
};
export type LocalContent<T extends FileType = FileType> = ProjectContent<T> & {
localEdit?: boolean;
error?: boolean;
};
export class Content
{
private static _ready = false;
private static initPromise?: Promise<boolean>;
private static root: FileSystemDirectoryHandle;
private static _overview: Record<string, Omit<LocalContent, 'content'>>;
private static queue = new AsyncQueue();
static init(): Promise<boolean>
{
if(Content._ready)
return Promise.resolve(true);
Content.initPromise = new Promise(async (res) => {
try
{
if(!('storage' in navigator))
return false;
Content.root = await navigator.storage.getDirectory();
Content.root.getDirectoryHandle('storage', { create: true });
const overview = await Content.read('overview', { create: true });
try
{
Content._overview = parse<Record<string, Omit<LocalContent, 'content'>>>(overview);
}
catch(e)
{
Content._overview = {};
await Content.pull();
}
Content._ready = true;
}
catch(e)
{
console.error(e);
}
res(Content._ready);
});
return Content.initPromise;
}
static getFromPath(path: string)
{
return Object.values(Content._overview).find(e => e.path === path);
}
static get(id: string)
{
return Content._overview[id];
}
static async getContent(id: string): Promise<LocalContent | undefined>
{
const overview = Content._overview[id];
if(!overview)
return;
return { ...overview, content: Content.fromString(overview, (await Content.read(id, { create: true }))!) };
}
static set(id: string, overview?: Omit<LocalContent, 'content'> | Recursive<Omit<LocalContent, 'content'>>)
{
if(overview === undefined)
{
delete Content._overview[id];
}
else
{
const {
id: _id,
path: _path,
owner: _owner,
title: _title,
timestamp: _timestamp,
navigable: _navigable,
private: _private,
order: _order,
type: _type,
...rest
} = overview as Recursive<LocalContent>;
Content._overview[id] = {
id: _id,
path: _path,
owner: _owner,
title: _title,
timestamp: _timestamp,
navigable: _navigable,
private: _private,
order: _order,
type: _type,
};
}
}
static async save(content?: ProjectContent)
{
const overviewAsString = JSON.stringify(Content._overview)
Content.queue.queue(() => Content.write("overview", overviewAsString));
if(content && content.content)
{
const contentAsString = Content.toString(content);
Content.queue.queue(() => Content.write(content.id, contentAsString));
}
return Content.queue.promise;
}
static async pull()
{
const overview = (await useRequestFetch()('/api/file/overview', { cache: 'no-cache' })) as ProjectContent<FileType>[] | undefined;
if(!overview)
{
//TODO: Cannot get data :'(
//Add a warning ?
return;
}
for(const file of overview)
{
const _overview = Content._overview[file.id];
if(_overview && _overview.localEdit)
{
//TODO: Ask what to do about this file.
}
else
{
Content._overview[file.id] = file;
Content.queue.queue(() => {
return useRequestFetch()(`/api/file/content/${file.id}`, { cache: 'no-cache' }).then(async (content: string | undefined) => {
if(content)
{
if(file.type !== 'folder')
{
Content.queue.queue(() => Content.write(file.id, content, { create: true }));
Content.queue.queue(() => Content.write('storage/' + file.path + (file.type === 'canvas' ? '.canvas' : '.md'), Content.toString({ ...file, content: Content.fromString(file, content) }), { create: true }));
}
}
else
Content._overview[file.id].error = true;
}).catch(e => {
Content._overview[file.id].error = true;
});
});
}
}
return Content.queue.queue(() => {
return Content.write('overview', JSON.stringify(Content._overview), { create: true });
});
}
static async push()
{
const blocked = (await useRequestFetch()('/api/file/overview', { method: 'POST', body: Object.values(Content._overview), cache: 'no-cache' }));
for(const [id, value] of Object.entries(Content._overview).filter(e => !blocked.includes(e[0])))
{
if(value.type === 'folder')
continue;
Content.queue.queue(() => Content.read(id).then(e => {
if(e) Content.queue.queue(() => useRequestFetch()(`/api/file/content/${id}`, { method: 'POST', body: e, cache: 'no-cache' }));
}));
}
return Content.queue.promise;
}
//Maybe store the file handles ? Is it safe to keep them ?
private static async read(path: string, options?: FileSystemGetFileOptions): Promise<string | undefined>
{
try
{
console.time(`Reading '${path}'`);
const handle = await Content.root.getFileHandle(path, options);
const file = await handle.getFile();
const text = await file.text();
console.timeEnd(`Reading '${path}'`);
return text;
}
catch(e)
{
console.error(path, e);
console.timeEnd(`Reading '${path}'`);
}
}
private static async goto(path: string, options?: FileSystemGetDirectoryOptions): Promise<FileSystemDirectoryHandle | undefined>
{
const splitPath = path.split("/");
let handle = Content.root;
try
{
for(const p of splitPath)
{
handle = await handle.getDirectoryHandle(p, options);
}
return handle;
}
catch(e)
{
return undefined;
}
}
//Easy to use, but not very performant.
private static async write(path: string, content: string, options?: FileSystemGetFileOptions): Promise<void>
{
const size = new TextEncoder().encode(content).byteLength;
console.time(`Writing ${size} bytes to '${path}'`);
try
{
const parent = path.split('/').slice(0, -1).join('/'), basename = path.split('/').slice(-1).join('/');
const handle = await (await Content.goto(parent, { create: true }) ?? Content.root).getFileHandle(basename, options);
const file = await handle.createWritable({ keepExistingData: false });
await file.write(content);
await file.close();
}
catch(e)
{
console.error(path, e);
}
console.timeEnd(`Writing ${size} bytes to '${path}'`);
}
static get estimate(): Promise<StorageEstimate>
{
return Content._ready ? navigator.storage.estimate() : Promise.reject();
}
static toString<T extends FileType>(content: ProjectContent<T>): string
{
return handlers[content.type].toString(content.content);
}
static fromString<T extends FileType>(overview: Omit<ProjectContent<T>, 'content'>, content: string): ContentMap[T]
{
return handlers[overview.type].fromString(content);
}
static render(parent: HTMLElement, path: string): Omit<LocalContent, 'content'> | undefined
{
const overview = Content.getFromPath(path);
if(!!overview)
{
const load = dom('div', { class: 'flex, flex-1 justify-center items-center' }, [loading('normal')]);
parent.appendChild(load);
function _render<T extends FileType>(content: LocalContent<T>): void
{
const el = handlers[content.type].render(content);
el && parent.replaceChild(el, load);
}
Content.getContent(overview.id).then(content => _render(content!));
}
else
{
parent.appendChild(dom('h2', { class: 'flex-1 text-center', text: "Impossible d'afficher le contenu demandé" }));
}
return overview;
}
static get files()
{
return Object.freeze(Content._overview);
}
static get tree()
{
const arr: Recursive<Omit<LocalContent, 'content'>>[] = [];
function addChild(arr: Recursive<Omit<LocalContent, 'content'>>[], overview: Omit<LocalContent, 'content'>): void {
const parent = arr.find(f => overview.path.startsWith(f.path));
if(parent)
{
if(!parent.children)
parent.children = [];
(overview as Recursive<typeof overview>).parent = parent;
addChild(parent.children, overview);
}
else
{
arr.push({ ...overview });
arr.sort((a, b) => {
if(a.order !== b.order)
return a.order - b.order;
return a.title.localeCompare(b.title);
});
}
}
for(const element of Object.values(Content._overview))
{
addChild(arr, {...element});
}
return arr;
}
static get ready(): Promise<boolean>
{
return Content._ready ? Promise.resolve(true) : Content.initPromise ?? Promise.resolve(false);
}
}
type ContentTypeHandler<T extends FileType> = {
toString: (content: ContentMap[T]) => string;
fromString: (str: string) => ContentMap[T];
render: (content: LocalContent<T>) => Node;
renderEditor: (content: LocalContent<T>) => Node;
};
function reshapeLinks(content: string | null, all: ProjectContent[])
{
return content?.replace(/\[\[(.*?)?(#.*?)?(\|.*?)?\]\]/g, (str, link, header, title) => {
return `[[${link ? parsePath(all.find(e => e.path.endsWith(parsePath(link)))?.path ?? parsePath(link)) : ''}${header ?? ''}${title ?? ''}]]`;
});
}
const handlers: { [K in FileType]: ContentTypeHandler<K> } = {
canvas: {
toString: (content) => {
const mapping: Record<string, string> = {
'red': '1',
'orange': '2',
'yellow': '3',
'green': '4',
'cyan': '5',
'purple': '6',
};
content.edges?.forEach(e => e.color = e.color ? e.color.hex ?? (e.color.class ? mapping[e.color.class]! : undefined) : undefined);
content.nodes?.forEach(e => e.color = e.color ? e.color.hex ?? (e.color.class ? mapping[e.color.class]! : undefined) : undefined);
return JSON.stringify(content);
},
fromString: (str) => JSON.parse(str),
render: (content) => {
const c = new Canvas(content.content);
queueMicrotask(() => c.mount());
return c.container;
},
renderEditor: (content) => {
let element: HTMLElement;
if(content.hasOwnProperty('content'))
{
const c = new CanvasEditor(content.content);
queueMicrotask(() => c.mount());
element = c.container;
}
else
{
element = loading("large");
Content.getContent(content.id).then(e => {
if(!e)
return element.parentElement?.replaceChild(dom('div', { class: '', text: 'Une erreur est survenue.' }), element);
content.content = e.content as CanvasContent;
const c = new CanvasEditor(content.content);
queueMicrotask(() => c.mount());
element.parentElement?.replaceChild(c.container, element);
});
}
return element;
},
},
markdown: {
toString: (content) => content,
fromString: (str) => str,
render: (content) => {
return dom('div', { class: 'flex flex-1 justify-start items-start flex-col lg:px-16 xl:px-32 2xl:px-64 py-6' }, [
dom('div', { class: 'flex flex-1 flex-row justify-between items-center' }, [
prose('h1', h1, [text(content.title)]),
dom('div', { class: 'flex gap-4' }, [
//TODO: Edition link
]),
]),
render(content.content),
])
},
renderEditor: (content) => {
let element: HTMLElement;
if(content.hasOwnProperty('content'))
{
MarkdownEditor.singleton.content = content.content;
element = MarkdownEditor.singleton.dom;
}
else
{
element = loading("large");
Content.getContent(content.id).then(e => {
if(!e)
return element.parentElement?.replaceChild(dom('div', { class: '', text: 'Une erreur est survenue.' }), element);
MarkdownEditor.singleton.content = content.content = (e as typeof content).content;
element.parentElement?.replaceChild(MarkdownEditor.singleton.dom, element);
});
}
MarkdownEditor.singleton.onChange = (value) => { content.content = value; };
return dom('div', { class: 'flex flex-1 justify-start items-start flex-col lg:px-16 xl:px-32 2xl:px-64 py-6' }, [
dom('div', { class: 'flex flex-row justify-between items-center' }, [ prose('h1', h1, [text(content.title)]) ]),
dom('div', { class: 'flex flex-1 w-full justify-stretch items-stretch py-2 px-1.5 font-sans text-base' }, [element]),
])
},
},
file: {
toString: (content) => content,
fromString: (str) => str,
render: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
},
map: {
toString: (content) => content,
fromString: (str) => str,
render: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
},
folder: {
toString: (_) => '',
fromString: (_) => null,
render: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
}
};
export const iconByType: Record<FileType, string> = {
'folder': 'lucide:folder',
'canvas': 'ph:graph-light',
'file': 'radix-icons:file',
'markdown': 'radix-icons:file-text',
'map': 'lucide:map',
};
export class Editor
{
tree: TreeDOM;
container: HTMLDivElement;
selected?: Recursive<LocalContent & { element?: HTMLElement }>;
private instruction: HTMLDivElement;
private cleanup: CleanupFn;
private history: History;
constructor()
{
this.history = new History();
this.history.register('overview', {
move: {
undo: (action) => {
this.tree.tree.remove(action.element.id);
action.element.parent = action.from.parent;
action.element.order = action.from.order;
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
this.tree.tree.insertAt(action.element, action.to.order as number);
action.element.element = this.tree.render(action.element, depth);
action.element?.cleanup();
this.dragndrop(action.element, depth, action.element.parent);
this.tree.update();
action.element.path = path;
},
redo: (action) => {
this.tree.tree.remove(action.element.id);
action.element.parent = action.to.parent;
action.element.order = action.to.order;
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
this.tree.tree.insertAt(action.element, action.to.order as number);
action.element.element = this.tree.render(action.element, depth);
action.element?.cleanup();
this.dragndrop(action.element, depth, action.element.parent);
this.tree.update();
action.element.path = path;
},
},
add: {
undo: (action) => {
this.tree.tree.remove(action.element.id);
if(this.selected === action.element) this.select();
action.element.cleanup();
action.element.element?.remove();
},
redo: (action) => {
if(!action.element)
{
const depth = getPath(action.element as LocalContent).split('/').length;
action.element.element = this.tree.render(action.element as LocalContent, depth) as HTMLElement;
this.dragndrop(action.element as LocalContent, depth, (action.element as Recursive<LocalContent>).parent);
}
this.tree.tree.insertAt(action.element as Recursive<LocalContent>, action.to as number);
this.tree.update();
},
},
remove: {
undo: (action) => {
this.tree.tree.insertAt(action.element, action.from as number);
this.tree.update();
},
redo: (action) => {
this.tree.tree.remove(action.element.id);
if(this.selected === action.element) this.select();
action.element.cleanup();
action.element.element?.remove();
},
},
rename: {
undo: (action) => {
action.element.title = action.from;
action.element.element!.children[0].children[1].textContent = action.from;
action.element.element!.children[0].children[1].setAttribute('title', action.from);
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
action.element?.cleanup();
this.dragndrop(action.element, depth, action.element.parent);
action.element.path = path;
},
redo: (action) => {
action.element.title = action.to;
action.element.element!.children[0].children[1].textContent = action.to;
action.element.element!.children[0].children[1].setAttribute('title', action.to);
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
action.element?.cleanup();
this.dragndrop(action.element, depth, action.element.parent);
action.element.path = path;
},
},
navigable: {
undo: (action) => {
action.element.navigable = action.from;
action.element.element!.children[0].children[2].children[0].replaceWith(icon(action.element.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !action.element.navigable }] }));
},
redo: (action) => {
action.element.navigable = action.to;
action.element.element!.children[0].children[2].children[0].replaceWith(icon(action.element.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !action.element.navigable }] }));
},
},
private: {
undo: (action) => {
action.element.private = action.from;
action.element.element!.children[0].children[3].children[0].replaceWith(icon(action.element.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !action.element.private }] }));
},
redo: (action) => {
action.element.private = action.to;
action.element.element!.children[0].children[3].children[0].replaceWith(icon(action.element.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !action.element.private }] }));
},
},
}, () => { this.tree.tree.each(e => Content.set(e.id, e)); Content.save(); });
this.tree = new TreeDOM((item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent)} }, [
icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 2 - 1.5}em` } }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
popper(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), { delay: 150, offset: 8, placement: 'left', arrow: true, content: [text('Navigable')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
popper(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
])]);
}, (item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent), click: () => this.select(item as LocalContent) } }, [
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 } }),
popper(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), { delay: 150, offset: 8, placement: 'left', arrow: true, content: [text('Navigable')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
popper(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
])]);
});
this.instruction = dom('div', { class: 'absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50' });
this.cleanup = this.setupDnD();
this.container = dom('div', { class: 'flex flex-1 flex-col items-start justify-start max-h-full relative' }, [dom('div', { class: 'py-4 flex-1 w-full max-h-full flex overflow-auto xl:px-12 lg:px-8 px-6 relative' })]);
this.select(this.tree.tree.find(useRouter().currentRoute.value.hash.substring(1)) as Recursive<LocalContent & { element?: HTMLElement }> | undefined);
}
private contextmenu(e: MouseEvent, item: Recursive<LocalContent>)
{
e.preventDefault();
const close = contextmenu(e.clientX, e.clientY, [
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.add("markdown", item); close() }} }, [icon('radix-icons:plus'), text('Ajouter')]),
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.rename(item); close() }} }, [icon('radix-icons:input'), text('Renommer')]),
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-red dark:text-dark-red', listeners: { click: (e) => { close(); confirm(`Confirmer la suppression de ${item.title}${item.children ? ' et de ses enfants' : ''} ?`).then(e => { if(e) this.remove(item)}) }} }, [icon('radix-icons:trash'), text('Supprimer')]),
], { placement: 'right-start', offset: 8 });
}
private add(type: FileType, nextTo: Recursive<LocalContent>)
{
const count = Object.values(Content.files).filter(e => e.title.match(/^Nouveau( \(\d+\))?$/)).length;
const item: Recursive<Omit<LocalContent, 'path' | 'content'> & { element?: HTMLElement }> = { id: getID(ID_SIZE), navigable: true, private: false, owner: 0, order: nextTo.order + 1, timestamp: new Date(), title: count === 0 ? 'Nouveau' : `Nouveau (${count})`, type: type, parent: nextTo.parent };
this.history.add('overview', 'add', [{ element: item, from: undefined, to: nextTo.order + 1 }]);
}
private remove(item: LocalContent & { element?: HTMLElement })
{
this.history.add('overview', 'remove', [{ element: item, from: item.order, to: undefined }], true);
}
private rename(item: LocalContent & { element?: HTMLElement })
{
let exists = true;
const change = () =>
{
const value = input.value || item.title;
if(exists)
{
exists = false;
input.parentElement?.replaceChild(text, input);
input.remove();
if(value !== item.title) this.history.add('overview', 'rename', [{ element: item, from: item.title, to: value }], true);
}
}
const text = item.element!.children[0].children[1];
const input = dom('input', { attributes: { type: 'text', value: item.title }, class: 'bg-light-20 dark:bg-dark-20 outline outline-light-35 dark:outline-dark-35 outline-offset-0 pl-1.5 py-1.5 flex-1', listeners: { mousedown: cancelPropagation, click: cancelPropagation, blur: change, change: change } });
text.parentElement?.replaceChild(input, text);
input.focus();
}
private toggleNavigable(e: Event, item: LocalContent & { element?: HTMLElement })
{
cancelPropagation(e);
this.history.add('overview', 'navigable', [{ element: item, from: item.navigable, to: !item.navigable }], true);
}
private togglePrivate(e: Event, item: LocalContent & { element?: HTMLElement })
{
cancelPropagation(e);
this.history.add('overview', 'private', [{ element: item, from: item.private, to: !item.private }], true);
}
private setupDnD(): CleanupFn
{
return combine(...this.tree.tree.accumulate(this.dragndrop.bind(this)), monitorForElements({
onDrop: ({ location }) => {
if (location.initial.dropTargets.length === 0)
return;
if (location.current.dropTargets.length === 0)
return;
const target = location.current.dropTargets[0];
const instruction = extractInstruction(target.data);
if (instruction !== null)
this.updateTree(instruction, location.initial.dropTargets[0].data.id as string, target.data.id as string);
},
}), autoScrollForElements({
element: this.tree.container,
}));
}
private dragndrop(item: Omit<LocalContent & { element?: HTMLElement, cleanup?: () => void }, "content">, depth: number, parent?: Omit<LocalContent & { element?: HTMLElement }, "content">): CleanupFn
{
item.cleanup && item.cleanup();
let opened = false, draggedOpened = false;
const element = item.element!;
item.cleanup = combine(draggable({
element,
onDragStart: () => {
element.classList.toggle('opacity-50', true);
opened = this.tree.opened(item)!;
this.tree.toggle(item, false);
},
onDrop: () => {
element.classList.toggle('opacity-50', false);
this.tree.toggle(item, opened);
},
canDrag: ({ element }) => {
return !element.querySelector('input[type="text"]');
}
}),
dropTargetForElements({
element,
getData: ({ input }) => {
const data = { id: item.id };
return attachInstruction(data, {
input,
element,
indentPerLevel: 16,
currentLevel: depth,
mode: !!(item as Recursive<typeof item>).children ? 'expanded' : parent ? ((parent as Recursive<typeof item>).children!.length === item.order + 1 ? 'last-in-group' : 'standard') : this.tree.tree.flatten.slice(-1)[0] === item ? 'last-in-group' : 'standard',
block: [],
})
},
canDrop: ({ source }) => {
return source.data.id !== getPath(item);
},
onDrag: ({ self }) => {
const instruction = extractInstruction(self.data) as Instruction;
if(instruction)
{
if('currentLevel' in instruction) this.instruction.style.width = `calc(100% - ${instruction.currentLevel / 2 - 1.5}em)`;
this.instruction.classList.toggle('!border-b-4', instruction?.type === 'reorder-below');
this.instruction.classList.toggle('!border-t-4', instruction?.type === 'reorder-above');
this.instruction.classList.toggle('!border-4', instruction?.type === 'make-child' || instruction?.type === 'reparent');
if(this.instruction.parentElement === null) element.appendChild(this.instruction);
}
},
onDragEnter: () => {
draggedOpened = this.tree.opened(item)!;
this.tree.toggle(item, true);
},
onDragLeave: () => {
this.tree.toggle(item, draggedOpened);
this.instruction.remove();
},
onDrop: () => {
this.tree.toggle(item, true);
this.instruction.remove();
},
getIsSticky: () => true,
}));
return item.cleanup;
}
private updateTree(instruction: Instruction, source: string, target: string)
{
const sourceItem = this.tree.tree.find(source);
const targetItem = this.tree.tree.find(target);
if(!sourceItem || !targetItem || instruction.type === 'instruction-blocked')
return;
const from = { parent: (sourceItem as Recursive<typeof targetItem>).parent, order: sourceItem.order };
if (source === target)
return;
if (instruction.type === 'reorder-above')
this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive<typeof targetItem>).parent, order: targetItem!.order }}], true);
if (instruction.type === 'reorder-below')
this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive<typeof targetItem>).parent, order: targetItem!.order + 1 }}], true);
if (instruction.type === 'make-child' && targetItem.type === 'folder')
this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: targetItem, order: 0 }}], true);
}
private render<T extends FileType>(item: LocalContent<T>): Node
{
return handlers[item.type].renderEditor(item);
}
private select(item?: LocalContent & { element?: HTMLElement })
{
if(this.selected && item)
{
Content.save(this.selected);
}
if(this.selected === item)
{
item?.element!.classList.remove('text-accent-blue');
this.selected = undefined;
}
else
{
this.selected?.element!.classList.remove('text-accent-blue');
item?.element!.classList.add('text-accent-blue');
this.selected = item;
}
useRouter().push({ hash: this.selected ? '#' + this.selected.id : '' })
this.container.firstElementChild!.replaceChildren();
this.selected && this.container.firstElementChild!.appendChild(this.render(this.selected) as HTMLElement);
}
unmount()
{
this.cleanup();
}
}
export function getPath(item: Recursive<Omit<LocalContent, 'content'>>): string
export function getPath(item: Omit<LocalContent, 'content'>): string
export function getPath(item: any): string
{
if(item.hasOwnProperty('parent') && item.parent !== undefined)
return [getPath(item.parent), parsePath(item.title)].filter(e => !!e).join('/');
else if(item.hasOwnProperty('parent'))
return parsePath(item.title);
else
return parsePath(item.title) ?? item.path;
}

171
shared/dom.util.ts Normal file
View File

@@ -0,0 +1,171 @@
import { iconExists, loadIcon } from 'iconify-icon';
export type Node = HTMLElement | SVGElement | Text | undefined;
export type NodeChildren = Array<Node>;
export type Class = string | Array<Class> | Record<string, boolean> | undefined;
type Listener<K extends keyof HTMLElementEventMap> = | ((ev: HTMLElementEventMap[K]) => any) | {
options?: boolean | AddEventListenerOptions;
listener: (ev: HTMLElementEventMap[K]) => any;
} | undefined;
export interface NodeProperties
{
attributes?: Record<string, string | undefined | boolean>;
text?: string;
class?: Class;
style?: Record<string, string | undefined | boolean | number> | string;
listeners?: {
[K in keyof HTMLElementEventMap]?: Listener<K>
};
}
export const cancelPropagation = (e: Event) => e.stopImmediatePropagation();
export function dom<K extends keyof HTMLElementTagNameMap>(tag: K, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[K]
{
const element = document.createElement(tag);
if(children && children.length > 0)
for(const c of children) if(c !== undefined) element.appendChild(c);
if(properties?.attributes)
for(const [k, v] of Object.entries(properties.attributes))
if(typeof v === 'string') element.setAttribute(k, v);
else if(typeof v === 'boolean') element.toggleAttribute(k, v);
if(properties?.text)
element.textContent = properties.text;
if(properties?.listeners)
{
for(let [k, v] of Object.entries(properties.listeners))
{
const key = k as keyof HTMLElementEventMap, value = v as Listener<typeof key>;
if(typeof value === 'function')
element.addEventListener(key, value);
else if(value)
element.addEventListener(key, value.listener, value.options);
}
}
styling(element, properties ?? {});
return element;
}
export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: Omit<NodeChildren, 'HTMLElement' | 'Text'>): SVGElementTagNameMap[K]
{
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
if(children && children.length > 0)
for(const c of children) if(c !== undefined) element.appendChild(c);
if(properties?.attributes)
for(const [k, v] of Object.entries(properties.attributes))
if(typeof v === 'string') element.setAttribute(k, v);
else if(typeof v === 'boolean') element.toggleAttribute(k, v);
if(properties?.text)
element.textContent = properties.text;
styling(element, properties ?? {});
return element;
}
export function text(data: string): Text
{
return document.createTextNode(data);
}
export function styling(element: SVGElement | HTMLElement, properties: {
class?: Class;
style?: Record<string, string | undefined | boolean | number> | string;
}): SVGElement | HTMLElement
{
if(properties?.class)
{
element.setAttribute('class', mergeClasses(properties.class));
}
if(properties?.style)
{
if(typeof properties.style === 'string')
{
element.setAttribute('style', properties.style);
}
else
for(const [k, v] of Object.entries(properties.style)) if(v !== undefined && v !== false) element.attributeStyleMap.set(k, v);
}
return element;
}
export interface IconProperties
{
mode?: string;
inline?: boolean;
noobserver?: boolean;
width?: string|number;
height?: string|number;
flip?: string;
rotate?: number|string;
style?: Record<string, string | undefined> | string;
class?: Class;
}
const iconCache: Map<IconProperties & { name: string }, HTMLElement> = new Map();
export function icon(name: string, properties?: IconProperties): HTMLElement
{
const key = { ...properties, name };
if(iconCache.has(key))
return iconCache.get(key)!.cloneNode() as HTMLElement;
const el = document.createElement('iconify-icon');
if(!iconExists(name))
loadIcon(name);
el.setAttribute('icon', name);
properties?.mode && el.setAttribute('mode', properties?.mode.toString());
properties?.inline && el.toggleAttribute('inline', properties?.inline);
properties?.noobserver && el.toggleAttribute('noobserver', properties?.noobserver);
properties?.width && el.setAttribute('width', properties?.width.toString());
properties?.height && el.setAttribute('height', properties?.height.toString());
properties?.flip && el.setAttribute('flip', properties?.flip.toString());
properties?.rotate && el.setAttribute('rotate', properties?.rotate.toString());
if(properties?.class)
{
el.setAttribute('class', mergeClasses(properties.class));
}
if(properties?.style)
{
if(typeof properties.style === 'string')
{
el.setAttribute('style', properties.style);
}
else
for(const [k, v] of Object.entries(properties.style)) if(v !== undefined) el.attributeStyleMap.set(k, v);
}
iconCache.set(key, el.cloneNode() as HTMLElement);
return el;
}
export function mergeClasses(classes: Class): string
{
if(typeof classes === 'string')
{
return classes.trim();
}
else if(Array.isArray(classes))
{
return classes.map(e => mergeClasses(e)).join(' ');
}
else if(classes)
{
return Object.entries(classes).filter(e => e[1]).map(e => e[0].trim()).join(' ');
}
else
{
return '';
}
}

View File

@@ -1,14 +1,14 @@
<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 { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, type DecorationSet } from '@codemirror/view';
import { Annotation, EditorState, 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 { bracketMatching, 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';
import { dom } from './dom.util';
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' });
@@ -99,24 +99,16 @@ class Decorator
return decorations;
}
}
</script>
<script setup lang="ts">
const { autofocus = false } = defineProps<{
placeholder?: string
autofocus?: boolean
}>();
const model = defineModel<string>();
export class MarkdownEditor
{
private static _singleton: MarkdownEditor;
const editor = useTemplateRef('editor');
const view = ref<EditorView>();
onMounted(() => {
if(editor.value)
private view: EditorView;
onChange?: (content: string) => void;
constructor()
{
view.value = new EditorView({
doc: model.value,
parent: editor.value,
this.view = new EditorView({
extensions: [
markdown({
base: markdownLanguage,
@@ -164,9 +156,7 @@ onMounted(() => {
]),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
{
model.value = viewUpdate.state.doc.toString();
}
this.onChange && this.onChange(viewUpdate.state.doc.toString());
}),
EditorView.contentAttributes.of({spellcheck: "true"}),
ViewPlugin.fromClass(Decorator, {
@@ -174,62 +164,88 @@ onMounted(() => {
})
]
});
}
focus()
{
this.view.focus();
}
if(autofocus)
set content(value: string)
{
if (value === undefined)
return;
const currentValue = this.view ? this.view.state.doc.toString() : "";
if (this.view && value !== currentValue)
{
view.value.focus();
this.view.dispatch({
changes: { from: 0, to: currentValue.length, insert: value || "" },
annotations: [External.of(true)],
});
}
}
});
onBeforeUnmount(() => {
if (view.value)
get content(): string
{
view.value?.destroy();
view.value = undefined;
return this.view.state.doc.toString();
}
});
watchEffect(() => {
if (model.value === void 0) {
return;
get dom()
{
return this.view.dom;
}
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)],
});
static get singleton(): MarkdownEditor
{
if(!MarkdownEditor._singleton)
MarkdownEditor._singleton = new MarkdownEditor();
return MarkdownEditor._singleton;
}
});
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
export class FramedEditor
{
@apply caret-light-100 dark:caret-dark-100;
}
.cm-line
{
@apply text-base;
@apply font-sans;
}
</style>
editor: MarkdownEditor;
dom: HTMLIFrameElement;
private static _singleton: FramedEditor;
static get singleton()
{
if(!FramedEditor._singleton)
FramedEditor._singleton = new FramedEditor();
return FramedEditor._singleton;
}
private constructor()
{
this.editor = MarkdownEditor.singleton;
this.dom = dom('iframe');
this.dom.addEventListener('load', () => {
if(!this.dom.contentDocument)
return;
this.dom.contentDocument.documentElement.setAttribute('class', document.documentElement.getAttribute('class') ?? '');
this.dom.contentDocument.documentElement.setAttribute('style', document.documentElement.getAttribute('style') ?? '');
const base = this.dom.contentDocument.head.appendChild(this.dom.contentDocument.createElement('base'));
base.setAttribute('href', window.location.href);
for(let element of document.getElementsByTagName('link'))
{
if(element.getAttribute('rel') === 'stylesheet')
this.dom.contentDocument.head.appendChild(element.cloneNode(true));
}
for(let element of document.getElementsByTagName('style'))
{
this.dom.contentDocument.head.appendChild(element.cloneNode(true));
}
this.dom.contentDocument.body.setAttribute('class', document.body.getAttribute('class') ?? '');
this.dom.contentDocument.body.setAttribute('style', document.body.getAttribute('style') ?? '');
this.dom.contentDocument.body.appendChild(this.editor.dom);
this.editor.focus();
})
}
}

272
shared/floating.util.ts Normal file
View File

@@ -0,0 +1,272 @@
import * as FloatingUI from "@floating-ui/dom";
import { cancelPropagation, dom, svg, text, type Class, type NodeChildren } from "./dom.util";
import { button } from "./proses";
export interface ContextProperties
{
placement?: FloatingUI.Placement;
offset?: number;
arrow?: boolean;
class?: Class;
}
export interface PopperProperties
{
placement?: FloatingUI.Placement;
offset?: number;
arrow?: boolean;
class?: Class;
content?: NodeChildren;
delay?: number;
onShow?: (element: HTMLDivElement) => boolean | void;
onHide?: (element: HTMLDivElement) => boolean | void;
}
export interface ModalProperties
{
priority?: boolean;
closeWhenOutside?: boolean;
}
let teleport: HTMLDivElement;
export function init()
{
teleport = dom('div', { attributes: { id: 'popper-container' }, class: 'absolute top-0 left-0' });
document.body.appendChild(teleport);
}
export function popper(container: HTMLElement, properties?: PopperProperties): HTMLElement
{
let shown = false, timeout: Timer;
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]);
const content = dom('div', { class: ['fixed hidden', properties?.class], attributes: { 'data-state': 'closed' } }, [...(properties?.content ?? []), arrow]);
function update()
{
FloatingUI.computePosition(container, content, {
placement: properties?.placement,
strategy: 'fixed',
middleware: [
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
FloatingUI.flip(),
properties?.offset ? FloatingUI.shift({ padding: properties?.offset }) : undefined,
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
]
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(content.style, {
left: `${x}px`,
top: `${y}px`,
});
const side = placement.split('-')[0] as FloatingUI.Side;
content.setAttribute('data-side', side);
if(properties?.offset && properties?.arrow)
{
const { x: arrowX, y: arrowY } = middlewareData.arrow!;
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[side]!;
const rotation = {
top: "0",
bottom: "180",
left: "270",
right: "90"
}[side]!;
Object.assign(arrow.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: `-6px`,
transform: `rotate(${rotation}deg)`,
});
}
});
}
let stop: () => void | undefined;
function show()
{
if(shown || !properties?.onShow || properties?.onShow(content) !== false)
{
clearTimeout(timeout);
timeout = setTimeout(() => {
if(!shown)
{
teleport!.appendChild(content);
content.setAttribute('data-state', 'open');
content.classList.toggle('hidden', false);
update();
stop = FloatingUI.autoUpdate(container, content, update, {
animationFrame: true,
layoutShift: false,
elementResize: false,
ancestorScroll: false,
ancestorResize: false,
});
}
shown = true;
}, properties?.delay ?? 0);
}
}
function hide()
{
if(!properties?.onHide || properties?.onHide(content) !== false)
{
clearTimeout(timeout);
timeout = setTimeout(() => {
content.remove();
stop && stop();
shown = false;
}, shown ? properties?.delay ?? 0 : 0);
}
}
function link(element: HTMLElement) {
Object.entries({
'mouseenter': show,
'mouseleave': hide,
'focus': show,
'blur': hide,
} as Record<keyof HTMLElementEventMap, () => void>).forEach(([event, listener]) => {
element.addEventListener(event, listener);
});
}
link(container);
link(content);
return container;
}
export function contextmenu(x: number, y: number, content: NodeChildren, properties?: ContextProperties): () => void
{
const virtual = {
getBoundingClientRect() {
return {
x: x,
y: y,
top: y,
left: x,
bottom: y,
right: x,
width: 0,
height: 0,
};
},
};
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]);
const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class] }, content);
function update()
{
FloatingUI.computePosition(virtual, container, {
placement: properties?.placement,
strategy: 'fixed',
middleware: [
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
FloatingUI.flip(),
properties?.offset ? FloatingUI.shift({ padding: properties?.offset }) : undefined,
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
]
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(container.style, {
left: `${x}px`,
top: `${y}px`,
});
const side = placement.split('-')[0] as FloatingUI.Side;
container.setAttribute('data-side', side);
if(properties?.offset && properties?.arrow)
{
const { x: arrowX, y: arrowY } = middlewareData.arrow!;
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[side]!;
const rotation = {
top: "0",
bottom: "180",
left: "270",
right: "90"
}[side]!;
Object.assign(arrow.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: `-6px`,
transform: `rotate(${rotation}deg)`,
});
}
});
}
update();
document.addEventListener('mousedown', close);
container.addEventListener('mousedown', cancelPropagation);
const stop = FloatingUI.autoUpdate(virtual, container, update, {
animationFrame: true,
layoutShift: false,
elementResize: false,
ancestorScroll: false,
ancestorResize: false,
});
teleport!.appendChild(container);
function close()
{
document.removeEventListener('mousedown', close);
container.removeEventListener('mousedown', cancelPropagation);
container.remove();
stop();
}
return close;
}
export function modal(content: NodeChildren, properties?: ModalProperties)
{
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (() => _modal.remove()) : undefined } });
const _closer = properties?.priority ? undefined : dom('span', { class: 'absolute top-4 right-4', text: '×', listeners: { click: () => _modal.remove() } });
const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, dom('div', { class: 'max-h-[85vh] max-w-[450px] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 text-light-100 dark:text-dark-100 z-10 relative' }, content)])
teleport.appendChild(_modal);
return {
close: () => _modal.remove(),
}
}
export function confirm(title: string): Promise<boolean>
{
return new Promise(res => {
const mod = modal([ dom('div', { class: 'flex flex-col justify-start gap-4' }, [ dom('div', { class: 'text-xl' }, [ text(title) ]), dom('div', { class: 'flex flex-row gap-2' }, [ button(text("Non"), () => (mod.close(), res(false)), 'h-[35px] px-[15px]'), button(text("Oui"), () => (mod.close(), res(true)), 'h-[35px] px-[15px] !border-light-red dark:!border-dark-red text-light:red dark:text-dark-red') ]) ]) ], {
priority: true,
closeWhenOutside: false,
});
})
}

View File

@@ -1,39 +1,21 @@
import type { CanvasContent } from '~/types/canvas';
import type { ContentMap, FileType } from '~/types/content';
export const ID_SIZE = 32;
export const DEFAULT_CONTENT: Record<FileType, ContentMap[FileType]['content']> = {
map: {},
canvas: { nodes: [], edges: []},
markdown: '',
file: '',
folder: null,
}
export function unifySlug(slug: string | string[]): string
{
return (Array.isArray(slug) ? slug.join('/') : slug);
}
export function group<
T,
K extends keyof T,
V extends keyof T,
KeyType extends string | number | symbol = Extract<T[K], string | number | symbol>
>(
table: T[],
key: K & (T[K] extends string | number | symbol ? K : never),
value: V
): Record<KeyType, T[V]> {
return table.reduce((p, v) => {
p[v[key] as KeyType] = v[value];
return p;
}, {} as Record<KeyType, T[V]>);
export function getID(length: number)
{
for (var id = [], i = 0; i < length; i++)
id.push((16 * Math.random() | 0).toString(16));
return id.join("");
}
export function parsePath(path: string): string
{
return path.toLowerCase().trim().replaceAll(" ", "-").normalize("NFD").replaceAll(/[\u0300-\u036f]/g, "").replaceAll('(', '').replaceAll(')', '');
return path.replace(/(\d+?)\. ?/, '').toLowerCase().replaceAll(" ", "-").normalize("NFD").replaceAll(/[\u0300-\u036f]/g, "").replaceAll('(', '').replaceAll(')', '').replace(/\-+/g, '-');
}
export function parseId(id: string | undefined): string | undefined
export function parseId(id: string | undefined): string |undefined
{
return id;
return id?.normalize('NFD')?.replace(/[\u0300-\u036f]/g, '')?.replace(/^\d\. */g, '')?.replace(/\s/g, "-")?.replace(/%/g, "-percent")?.replace(/\?/g, "-q")?.toLowerCase();
}
export function padLeft(text: string, pad: string, length: number): string
@@ -70,40 +52,4 @@ export function clamp(x: number, min: number, max: number): number
export function lerp(x: number, a: number, b: number): number
{
return (1-x)*a+x*b;
}
export function convertContentFromText(type: FileType, content: string): CanvasContent | string {
switch(type)
{
case 'canvas':
return JSON.parse(content) as CanvasContent;
case 'map':
case 'file':
case 'folder':
case 'markdown':
return content;
default:
return content;
}
}
export function convertContentToText(type: FileType, content: any): string {
switch(type)
{
case 'canvas':
return JSON.stringify(content);
case 'map':
case 'file':
case 'folder':
case 'markdown':
return content;
default:
return content;
}
}
export const iconByType: Record<FileType, string> = {
'folder': 'lucide:folder',
'canvas': 'ph:graph-light',
'file': 'radix-icons:file',
'markdown': 'radix-icons:file-text',
'map': 'lucide:map',
}

94
shared/history.util.ts Normal file
View File

@@ -0,0 +1,94 @@
export type HistoryHandler = {
undo: (action: HistoryAction) => void;
redo: (action: HistoryAction) => void;
}
interface HistoryEvent
{
source: string;
event: string;
actions: HistoryAction[];
}
interface HistoryAction
{
element: any;
from?: any;
to?: any;
}
export class History
{
private handlers: Record<string, { handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => any }>;
private history: HistoryEvent[];
private position: number;
constructor()
{
this.handlers = {};
this.history = [];
this.position = -1;
}
get last()
{
return this.history.length > 0 && this.position > -1 ? this.history[this.position] : undefined;
}
get undoable()
{
return this.history && this.position !== -1;
}
get redoable()
{
return this.history && this.position < this.history.length - 1;
}
undo()
{
const last = this.last;
if(!last)
return;
last.actions.forEach(e => {
this.handlers[last.source] && this.handlers[last.source].handlers[last.event]?.undo(e)
this.handlers[last.source] && this.handlers[last.source].any && this.handlers[last.source].any!(e);
});
this.position--;
}
redo()
{
if(!this.history || this.history.length - 1 <= this.position)
return;
this.position++;
const last = this.last;
if(!last)
{
this.position--;
return;
}
last.actions.forEach(e => {
this.handlers[last.source] && this.handlers[last.source].handlers[last.event]?.redo(e)
this.handlers[last.source] && this.handlers[last.source].any && this.handlers[last.source].any!(e);
});
}
add(source: string, event: string, actions: HistoryAction[], apply: boolean = false)
{
this.position++;
this.history.splice(this.position, history.length - this.position, { source, event, actions });
if(apply)
actions.forEach(e => {
this.handlers[source] && this.handlers[source].handlers[event]?.redo(e);
this.handlers[source] && this.handlers[source].any && this.handlers[source].any(e);
});
}
register(source: string, handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => any)
{
this.handlers[source] = { handlers, any };
}
unregister(source: string)
{
delete this.handlers[source];
}
}

View File

@@ -1,670 +0,0 @@
import { defineMode, type Mode, getMode, StringStream, startState } from "codemirror";
import type { MarkdownState } from "hypermd/mode/hypermd";
const EN = /^(?:[*\-+]|^[0-9]+([.)]))\s+/, SN = /^(?:(?:(?:aaas?|about|acap|adiumxtra|af[ps]|aim|apt|attachment|aw|beshare|bitcoin|bolo|callto|cap|chrome(?:-extension)?|cid|coap|com-eventbrite-attendee|content|crid|cvs|data|dav|dict|dlna-(?:playcontainer|playsingle)|dns|doi|dtn|dvb|ed2k|facetime|feed|file|finger|fish|ftp|geo|gg|git|gizmoproject|go|gopher|gtalk|h323|hcp|https?|iax|icap|icon|im|imap|info|ipn|ipp|irc[6s]?|iris(?:\.beep|\.lwz|\.xpc|\.xpcs)?|itms|jar|javascript|jms|keyparc|lastfm|ldaps?|magnet|mailto|maps|market|message|mid|mms|ms-help|msnim|msrps?|mtqp|mumble|mupdate|mvn|news|nfs|nih?|nntp|notes|oid|opaquelocktoken|palm|paparazzi|platform|pop|pres|proxy|psyc|query|res(?:ource)?|rmi|rsync|rtmp|rtsp|secondlife|service|session|sftp|sgn|shttp|sieve|sips?|skype|sm[bs]|snmp|soap\.beeps?|soldat|spotify|ssh|steam|svn|tag|teamspeak|tel(?:net)?|tftp|things|thismessage|tip|tn3270|tv|udp|unreal|urn|ut2004|vemmi|ventrilo|view-source|webcal|wss?|wtai|wyciwyg|xcon(?:-userid)?|xfire|xmlrpc\.beeps?|xmpp|xri|ymsgr|z39\.50[rs]?):(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]|\([^\s()<>]*\))+(?:\([^\s()<>]*\)|[^\s`*!()\[\]{};:'".,<>?«»“”‘’]))/i, xN = /^(?:(?:[^<>()[\]\\.,;:\s@\"`]+(?:\.[^<>()[\]\\.,;:\s@\"]+)*)|(?:\".+\"))@(?:(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(?:(?:[a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))\b/, TN = /^(?:[^\u2000-\u206F\u2E00-\u2E7F'!"#$%&()*+,.:;<=>?@^`{|}~\[\]\\\s])+/;
const PN = /^\s*[^\|].*?\|.*[^|]\s*$/, IN = /^\s*[^\|].*\|/, FN = /^\|(?:[^|]+\|)+?\s*$/, ON = /^\|/, BN = /^\s*-+\s*:\s*$/, NN = /^\s*:\s*-+\s*$/, RN = /^\s*:\s*-+\s*:\s*$/, HN = /^\s*-+\s*$/;
const readSideRegex = /(?:([\u200F\p{sc=Arabic}\p{sc=Hebrew}\p{sc=Syriac}\p{sc=Thaana}])|([\u200E\p{sc=Armenian}\p{sc=Bengali}\p{sc=Bopomofo}\p{sc=Braille}\p{sc=Buhid}\p{sc=Canadian_Aboriginal}\p{sc=Cherokee}\p{sc=Cyrillic}\p{sc=Devanagari}\p{sc=Ethiopic}\p{sc=Georgian}\p{sc=Greek}\p{sc=Gujarati}\p{sc=Gurmukhi}\p{sc=Han}\p{sc=Hangul}\p{sc=Hanunoo}\p{sc=Hiragana}\p{sc=Kannada}\p{sc=Katakana}\p{sc=Khmer}\p{sc=Lao}\p{sc=Latin}\p{sc=Limbu}\p{sc=Malayalam}\p{sc=Mongolian}\p{sc=Myanmar}\p{sc=Ogham}\p{sc=Oriya}\p{sc=Runic}\p{sc=Sinhala}\p{sc=Tagalog}\p{sc=Tagbanwa}\p{sc=Tamil}\p{sc=Telugu}\p{sc=Thai}\p{sc=Tibetan}\p{sc=Yi}]))/u;
const readSide = function(e: string) {
var t = e.match(readSideRegex);
if (t) {
if (t[1])
return "rtl";
if (t[2])
return "ltr"
}
return "auto"
}
const isLetter = (e: string) => /[a-z]/i.test(e);
const clearSubstringWords = (str: string, substring: string) => str.replace(new RegExp("\\s?[^\\s]*".concat(substring, "[^\\s]*","g")), "");
const enum HashtagType {
NONE = 0,
NORMAL = 1,
WITH_SPACE = 2
}
const enum TableType {
NONE = 0,
SIMPLE = 1,
NORMAL = 2
}
const enum NextMaybe {
NONE = 0,
FRONT_MATTER = 1,
FRONT_MATTER_END = 2
}
const enum LinkType {
NONE = 0,
BARELINK = 1,
FOOTREF = 2,
NORMAL = 3,
FOOTNOTE = 4,
MAYBE_FOOTNOTE_URL = 5,
MAYBE_FOOTNOTE_URL_TITLE = 6,
BARELINK2 = 7,
FOOTREF2 = 8,
INTERNAL_LINK = 9,
EMBED = 10,
}
const CLASSES: Record<number, string> = {
1: "hmd-barelink",
7: "hmd-barelink2",
2: "hmd-barelink footref",
4: "hmd-barelink hmd-footnote line-HyperMD-footnote",
8: "hmd-footref2",
9: "hmd-internal-link",
10: "hmd-internal-link hmd-embed",
}
export declare type TokenFunc = (stream: CodeMirror.StringStream, state: HyperMDState) => string;
export declare type InnerModeExitChecker = (stream: CodeMirror.StringStream, state: HyperMDState) => {
endPos?: number;
skipInnerMode?: boolean;
style?: string;
} | null;
interface HyperMDState extends MarkdownState {
hmdTableRTL: boolean;
highlight: boolean;
hasAlias: boolean;
isAlias: boolean;
comment: boolean;
mathed: boolean;
internalEmbed: any;
internalLink: any;
inFootnote: boolean;
inlineFootnote: boolean;
wasHeading: boolean;
isHeading: boolean;
hmdTable: TableType;
hmdTableID: string | null;
hmdTableColumns: string[];
hmdTableCol: number;
hmdTableRow: number;
hmdOverride: TokenFunc | null;
hmdHashtag: HashtagType | boolean;
hmdInnerStyle: string;
hmdInnerExitChecker: InnerModeExitChecker | null;
hmdInnerMode: CodeMirror.Mode<any> | null;
hmdInnerState: any;
hmdLinkType: LinkType;
hmdNextMaybe: NextMaybe;
hmdNextState: HyperMDState | null;
hmdNextStyle: string | null;
hmdNextPos: number | null;
}
function resetTable(state: HyperMDState)
{
state.hmdTable = TableType.NONE,
state.hmdTableRTL = !1,
state.hmdTableColumns = [],
state.hmdTableID = null,
state.hmdTableCol = state.hmdTableRow = 0
}
defineMode('d-any', function(cm, config) {
const markdownMode: Mode<MarkdownState> = getMode(cm, { ...config, name: 'markdown' }) as any;
const mode: Mode<HyperMDState> = getMode(cm, { ...config, name: 'hypermd' }) as any;
config = Object.assign({}, {
front_matter: !0,
math: !0,
table: !0,
toc: !0,
orgModeMarkup: !0,
hashtag: !0,
fencedCodeBlockHighlighting: !0,
highlightFormatting: !0,
taskLists: !0,
strikethrough: !0,
emoji: !1,
highlight: !0,
headers: !0,
blockquotes: !0,
indentedCode: !0,
lists: !0,
hr: !0,
blockId: !0
}, config)
function modeOverride(stream: CodeMirror.StringStream, state: HyperMDState): string {
const exit = state.hmdInnerExitChecker!(stream, state);
const extraStyle = state.hmdInnerStyle;
let ans = (!exit || !exit.skipInnerMode) && state.hmdInnerMode!.token(stream, state.hmdInnerState) || "";
if (extraStyle) ans += " " + extraStyle;
if (exit) {
if (exit.style) ans += " " + exit.style;
if (exit.endPos) stream.pos = exit.endPos;
state.hmdInnerExitChecker = null;
state.hmdInnerMode = null;
state.hmdInnerState = null;
state.hmdOverride = null;
}
return ans.trim();
}
function advanceMarkdown(stream: CodeMirror.StringStream, state: HyperMDState) {
if (stream.eol() || state.hmdNextState) return false;
let oldStart = stream.start;
let oldPos = stream.pos;
stream.start = oldPos;
let newState = { ...state };
let newStyle = mode.token(stream, newState);
state.hmdNextPos = stream.pos;
state.hmdNextState = newState;
state.hmdNextStyle = newStyle;
stream.start = oldStart;
stream.pos = oldPos;
return true;
}
function createDummyMode(endTag: string): CodeMirror.Mode<void> {
return {
token(stream) {
let endTagSince = stream.string.indexOf(endTag, stream.start);
if (endTagSince === -1) stream.skipToEnd(); // endTag not in this line
else if (endTagSince === 0) stream.pos += endTag.length; // current token is endTag
else {
stream.pos = endTagSince;
if (stream.string.charAt(endTagSince - 1) === "\\") stream.pos++;
}
return null;
}
}
}
function createSimpleInnerModeExitChecker(endTag: string, retInfo?: ReturnType<InnerModeExitChecker>): InnerModeExitChecker {
if (!retInfo) retInfo = {};
return function (stream, state) {
if (stream.string.substring(stream.start, stream.start + endTag.length) === endTag) {
retInfo.endPos = stream.start + endTag.length;
return retInfo;
}
return null;
}
}
interface BasicInnerModeOptions {
skipFirstToken?: boolean
style?: string
}
interface InnerModeOptions1 extends BasicInnerModeOptions {
fallbackMode: () => CodeMirror.Mode<any>
exitChecker: InnerModeExitChecker
}
interface InnerModeOptions2 extends BasicInnerModeOptions {
endTag: string
}
type InnerModeOptions = InnerModeOptions1 | InnerModeOptions2
/**
* switch to another mode
*
* After entering a mode, you can then set `hmdInnerExitStyle` and `hmdInnerState` of `state`
*
* @returns if `skipFirstToken` not set, returns `innerMode.token(stream, innerState)`, meanwhile, stream advances
*/
function enterMode(stream: StringStream, state: HyperMDState, mode: string | CodeMirror.Mode<any> | null, opt: InnerModeOptions): string {
if (typeof mode === "string") mode = getMode(cm, mode);
if (!mode || mode["name"] === "null") {
if ('endTag' in opt) mode = createDummyMode(opt.endTag);
else if(typeof opt.fallbackMode === 'function') mode = opt.fallbackMode();
if (!mode) throw new Error("no mode");
}
state.hmdInnerExitChecker = ('endTag' in opt) ? createSimpleInnerModeExitChecker(opt.endTag) : opt.exitChecker;
state.hmdInnerStyle = opt.style ?? '';
state.hmdInnerMode = mode;
state.hmdOverride = modeOverride;
state.hmdInnerState = startState(mode);
let ans = opt.style || "";
if (!opt.skipFirstToken)
ans += " " + mode.token(stream, state.hmdInnerState);
return ans.trim();
}
const i: Record<string, any> = {
htmlBlock: null,
block: null
};
return {
name: 'd-any',
...mode,
token(stream, _state) {
const state = _state as HyperMDState;
stream.tabSize = 4;
if (state.hmdOverride)
return state.hmdOverride(stream, state);
if (state.hmdTable && " " === stream.peek())
{
if ("|" === stream.string[stream.pos - 1] && "\\" !== stream.string[stream.pos - 2])
return stream.match(/^ +/), "";
if (stream.match(/^ +\|/))
return stream.backUp(1), "";
}
if (state.hmdNextMaybe === NextMaybe.FRONT_MATTER)
{
if ("---" === stream.string)
return state.hmdNextMaybe = NextMaybe.FRONT_MATTER_END, enterMode(stream, state, "yaml", {
style: "hmd-frontmatter",
fallbackMode: function() {
return createDummyMode("---");
},
exitChecker: function(e, stream) {
return e.string.startsWith("---") && "" === e.string.substring(3).trim() ? (stream.hmdNextMaybe = NextMaybe.NONE,
{
endPos: e.string.length
}) : null;
}
});
state.hmdNextMaybe = NextMaybe.NONE;
}
let a = state.f === i.htmlBlock, c = -1 === state.code, u = state.quote, h = 0 === stream.start;
h && (state.inFootnote && state.hmdLinkType === LinkType.MAYBE_FOOTNOTE_URL || (state.hmdLinkType = LinkType.NONE),
state.inlineFootnote = !1,
state.wasHeading = state.isHeading,
state.isHeading = !1,
!state.code || 1 !== state.code && 2 !== state.code || (state.code = 0));
let d, p, f = state.linkText, m = state.linkHref, v = !(c || a), g = v && !(state.code || state.indentedCode || state.linkHref), y = "", b = !1, w = -1, k = !1;
if (v)
{
if (g && "\\" === stream.peek() && (k = !0), state.list && !state.header && "#" === stream.peek() && /^\s*[*\-+]\s$/.test(stream.string.substring(0, stream.pos)))
{
const C = stream.match(/^(#+)(?: |$)/, !0);
if (C)
{
const M = C[1].length;
return state.header = M, "formatting formatting-header formatting-header-" + M + " header header-" + M;
}
}
if (config.math && g && "$" === stream.peek() && (state.hmdLinkType === LinkType.NONE || state.hmdLinkType === LinkType.MAYBE_FOOTNOTE_URL) && !state.internalLink && !state.internalEmbed)
{
let E: string[] | null = stream.match(/^(\$)[^\s$]/, !1), S = stream.match(/^(\${2})/, !1), x = E ? "$" : "$$";
if (E)
{
let I = stream.string.slice(stream.pos + 1).match(/[^\\]\$(.|$)/)
if(!I || !I[0].match(/^[^\s\\]\$([^0-9]|$)/))
E = null;
}
let T = !1;
if (E || S)
{
if (0 !== stream.pos || state.mathed)
{
let D = "math";
state.quote && (D += " line-HyperMD-quote line-HyperMD-quote-" + state.quote + " line-HyperMD-quote-lazy");
const A = getMode(cm, {
name: "stex"
}), L = "stex" !== A.name;
return y += enterMode(stream, state, A, {
style: D,
skipFirstToken: L,
fallbackMode: function() {
return createDummyMode(x);
},
exitChecker: function(e, stream) {
let n = e.start, i = e.string, r = "formatting formatting-math formatting-math-end math-";
return stream.hmdTable && "|" === i[n] ? {
endPos: n,
style: r
} : i.substring(n, n + x.length) === x ? {
endPos: n + x.length,
style: r
} : null;
}
}),
E ? (L && (stream.pos += E[1].length),
y += " formatting formatting-math formatting-math-begin") : (L && (stream.pos += S[1].length),
y += " formatting formatting-math formatting-math-begin math-block")
}
T = !0, w = 0;
}
state.mathed = T;
}
if (g) {
state.internalLink ? (state.hmdLinkType = LinkType.INTERNAL_LINK,
state.internalLink = !1) : state.internalEmbed && (state.hmdLinkType = LinkType.EMBED,
state.internalEmbed = !1);
let P = state.hmdLinkType === LinkType.INTERNAL_LINK || state.hmdLinkType === LinkType.EMBED;
if (P)
if ("|" === stream.peek())
state.isAlias = !0,
w = stream.pos + 1,
y += " link-alias-pipe";
else if ("]" === stream.peek() && stream.match("]]", !1))
state.hmdLinkType = LinkType.NONE,
state.linkText = !1,
state.isAlias = !1,
state.hasAlias = !1,
w = stream.pos + 2,
y += " formatting-link formatting-link-end";
else {
b = !0,
state.isAlias && (y += " link-alias"),
state.hasAlias && !state.isAlias && (y += " link-has-alias");
let I = stream.match(/^([^|\]]*?)/, !1);
w = stream.pos + Math.max(1, I[0].length)
}
else if("!" === stream.peek() || "[" === stream.peek())
{
const I = stream.match(/^(!?\[\[)(.*?)]]/, !1)
if(I)
"!" === I[1].charAt(0) ? (y += " formatting-link formatting-link-start formatting-embed", state.internalEmbed = !0) : (y += " formatting-link formatting-link-start", state.internalLink = !0), w = stream.pos + I[1].length, state.hasAlias = I[2].includes("|");
}
if (state.hmdLinkType === LinkType.FOOTREF)
if (b = !0,
"]" === stream.peek())
state.hmdLinkType = LinkType.NONE,
w = stream.pos + 1,
y += " formatting formatting-link formatting-link-end " + CLASSES[LinkType.FOOTREF];
else {
let I = stream.match(/^([^\]]*?)/, !1);
w = stream.pos + Math.max(1, I[0].length)
}
else
{
const I = stream.peek() === "[" && stream.match(/^\[\^([^\]\s]*?)\](:?)/, false);
if(I && (h || I[2]))
{
stream.match("[^"),
y += " formatting formatting-link formatting-link-start",
state.hmdLinkType = LinkType.FOOTREF,
w = stream.pos;
}
}
if (config.blockId && "^" === stream.peek() && stream.match(/^\^([a-zA-Z0-9\-]+)$/))
return y += " blockid";
!state.inlineFootnote && "^" === stream.peek() && stream.match("^[", !1) ? (state.inlineFootnote = !0,
y += " inline-footnote-start formatting-inline-footnote",
w = stream.pos + 2) : state.inlineFootnote && !P && state.hmdLinkType === LinkType.NONE && !state.image && stream.match("]") && (state.inlineFootnote = !1,
y += " footref inline-footnote inline-footnote-end formatting-inline-footnote",
w = stream.pos),
"%" === stream.peek() && stream.match("%%", !1) ? (state.comment ? (y += " comment formatting comment-end",
state.comment = !1) : (y += " comment formatting comment-start",
state.comment = !0),
w = stream.pos + 2) : state.comment && (y += " comment")
}
if (g && (state.hmdLinkType || state.image || state.linkText || (isLetter(stream.peek()!) && stream.match(SN) || (p = stream.peek(),
!/[\s<>()[\]\\.,;:\s@\"`]/.test(p!) && stream.match(xN))) && (y += " url",
w = stream.pos)),
h && state.inFootnote) {
let F = stream.match(/^\s*/, !1)[0].replace(/\stream/g, " ").length;
F && F % stream.tabSize == 0 ? y += " line-HyperMD-footnote" : state.inFootnote = !1
}
let O = h && "[" === stream.peek() && stream.match(/^\[((?:[^\]\\]|\\.)*)\]:/, !1);
if (O) {
let B = O[1];
if ("^" !== B[0] || !/\s/.test(B))
return stream.match(/\[\^?/),
state.hmdLinkType = LinkType.FOOTNOTE,
state.formatting = "link",
state.linkText = !0,
y += "formatting formatting-link link " + CLASSES[LinkType.FOOTNOTE]
} else if (state.hmdLinkType === LinkType.FOOTNOTE) {
if ("]" === stream.peek() && stream.match("]:"))
return y += " formatting formatting-link link " + CLASSES[LinkType.FOOTNOTE],
state.linkText = !1,
state.inFootnote = !0,
state.hmdLinkType = LinkType.MAYBE_FOOTNOTE_URL,
//@ts-ignore
state.f = state.inline = markdownMode.startState().inline,
y;
y += " link " + CLASSES[LinkType.FOOTNOTE]
} else if (state.hmdLinkType === LinkType.MAYBE_FOOTNOTE_URL) {
if (stream.eatSpace())
return y;
if (isLetter(stream.peek()!) && stream.match(SN))
return y += " url hmd-footnote-url",
state.hmdLinkType = LinkType.MAYBE_FOOTNOTE_URL_TITLE,
y;
state.hmdLinkType = LinkType.NONE
} else if (state.hmdLinkType === LinkType.MAYBE_FOOTNOTE_URL_TITLE) {
if (stream.eatSpace())
return y;
if (state.hmdLinkType = LinkType.NONE,
stream.match(/^(["']).*?\1/) || stream.match(/^\([^)]*?\)/))
return y += " hmd-footnote-url-title"
}
}
if (state.hmdTable && "|" === stream.peek() && "\\" !== stream.string[stream.pos - 1] && function(e)
{
e.code = !1,
e.comment = !1,
e.em = !1,
e.formatting = !1,
e.highlight = !1,
e.hmdHashtag = !1,
e.hmdLinkType = LinkType.NONE,
e.isAlias = !1,
e.internalEmbed = !1,
e.internalLink = !1,
e.linkHref = !1,
e.linkText = !1,
e.linkTitle = !1,
e.strikethrough = !1,
e.strong = !1
}(state),
state.hmdNextState)
stream.pos = state.hmdNextPos!,
y += " " + (state.hmdNextStyle || ""),
Object.assign(state, state.hmdNextState),
state.hmdNextState = null,
state.hmdNextStyle = null,
state.hmdNextPos = null;
else {
let N = h && 0 !== stream.pos;
if (b) {
//@ts-ignore
let R = markdownMode.copyState(state), H = stream.pos;
y += " " + (markdownMode.token(stream, R) || ""),
stream.pos = H
} else
y += " " + (markdownMode.token(stream, state) || "");
//@ts-ignore
N && state.f === state.block && (state.f = state.inline = markdownMode.startState().inline),
state.inFootnote && (state.indentationDiff = 0)
}
y = function(e, text) {
return text ? (!config.hr && e.hr && (text = clearSubstringWords(text, "hr"),
e.hr = !1),
!config.headers && e.header && (text = clearSubstringWords(text, "header"),
e.header = 0),
!config.indentedCode && e.indentedCode && (text = clearSubstringWords(text, "inline-code"),
e.indentedCode = !1),
!config.blockquotes && e.quote && (text = clearSubstringWords(text, "quote"),
e.quote = 0),
!config.lists && e.list && (text = clearSubstringWords(text, "list"),
e.list = !1),
text) : text
}(state, y),
y.includes("formatting-task") && (y += " line-HyperMD-task-line"),
state.hmdHashtag && (y += " " + config.tokenTypeOverrides.hashtag),
-1 !== w && (stream.pos = w),
state.header && (state.isHeading = !0),
!i.htmlBlock && state.htmlState && (i.htmlBlock = state.f);
let V = state.f === i.htmlBlock
, z = -1 === state.code;
if (v = v && !(V || z),
g = g && v && !(state.code || state.indentedCode || state.linkHref),
state.hmdTable && V) {
let q = stream.current();
/(?:^|[^\\])\|/.test(q) && ("" === y.trim() || /string|attribute/.test(y)) && (V = !1,
a = !1,
state.htmlState = null,
state.block = i.block,
//@ts-ignore
state.f = state.inline = markdownMode.startState().inline,
stream.pos = "|" === q ? stream.start : stream.start + 1)
}
let U = stream.current();
if (V !== a && (V ? (y += " hmd-html-begin",
i.htmlBlock = state.f) : y += " hmd-html-end"),
(c || z) && (state.localMode && c || (y = y.replace("inline-code", "")),
y += " line-HyperMD-codeblock line-background-HyperMD-codeblock-bg hmd-codeblock",
z !== c && (z ? c || (y += " line-HyperMD-codeblock-begin line-background-HyperMD-codeblock-begin-bg") : y += " line-HyperMD-codeblock-end line-background-HyperMD-codeblock-end-bg")),
v) {
let _ = state.hmdTable;
if (h && _)
(_ == TableType.SIMPLE ? IN : ON).test(stream.string) ? (state.hmdTableCol = 0,
state.hmdTableRow++) : resetTable(state);
if (h && state.header && (/^(?:---+|===+)\s*$/.test(stream.string) && state.prevLine && state.prevLine.header ? y += " line-HyperMD-header-line line-HyperMD-header-line-" + state.header : y += " line-HyperMD-header line-HyperMD-header-" + state.header),
state.indentedCode && (y += " hmd-indented-code"),
state.quote) {
if (stream.match(/^\s*>/, !1) && !stream.eol() || (y += " line-HyperMD-quote line-HyperMD-quote-" + state.quote,
/^ {0,3}\>/.test(stream.string) || (y += " line-HyperMD-quote-lazy")),
h && (d = U.match(/^\s+/)))
return stream.pos = d![0].length,
(y += " hmd-indent-in-quote").trim();
if (state.quote > u)
{
const I = "[" === stream.peek() && stream.match(/^\[!([^\]]+)\]([+\-]?)(?:\s|$)/);
if(I)
y += " line-HyperMD-callout hmd-callout line-HyperMD-quote line-HyperMD-quote-" + state.quote
}
}
let W = (state.listStack[state.listStack.length - 1] || 0) + 3
, j = h && /^\s+$/.test(U) && (!1 !== state.list || stream.indentation() <= W)
, G = state.list && y.includes("formatting-list");
if (G || j && (!1 !== state.list || stream.match(EN, !1))) {
let K = state.listStack && state.listStack.length || 0;
if (j) {
if (stream.match(EN, !1))
!1 === state.list && K++;
else {
for (; K > 0 && stream.pos < state.listStack[K - 1]; )
K--;
if (!K)
return y.trim() || null;
y += " line-HyperMD-list-line-nobullet line-HyperMD-list-line line-HyperMD-list-line-".concat(K.toString())
}
y += " hmd-list-indent hmd-list-indent-".concat(K.toString())
} else
G && (y += " line-HyperMD-list-line line-HyperMD-list-line-".concat(K.toString()))
}
if (f !== state.linkText && (f || state.internalLink || state.internalEmbed ? state.hmdLinkType !== LinkType.FOOTNOTE && (state.hmdLinkType in CLASSES && (y += " " + CLASSES[state.hmdLinkType]),
state.hmdLinkType = LinkType.NONE) : (d = stream.match(/^([^\]]+)\](\(| ?\[|\:)?/, !1)) ? d[2] ? "[" !== d[2] && " [" !== d[2] || "]" !== stream.string.charAt(stream.pos + d[0].length) ? state.hmdLinkType = LinkType.NORMAL : state.hmdLinkType = LinkType.BARELINK2 : "^" !== d[1][0] || /\s/.test(d[1]) ? state.hmdLinkType = LinkType.BARELINK : state.hmdLinkType = LinkType.FOOTREF : state.hmdLinkType = LinkType.BARELINK),
m !== state.linkHref && (m ? state.hmdLinkType && (y += " " + CLASSES[state.hmdLinkType],
state.hmdLinkType = LinkType.NONE) : "[" === U && "]" !== stream.peek() && (state.hmdLinkType = LinkType.FOOTREF2)),
state.hmdLinkType !== LinkType.NONE && state.hmdLinkType in CLASSES && (y += " " + CLASSES[state.hmdLinkType]),
state.inlineFootnote && (y += " footref inline-footnote"),
k && U.length > 1) {
let Y = U.length - 1
, Z = y.replace("formatting-escape", "escape") + " hmd-escape-char";
return state.hmdOverride = function(e, stream) {
return e.pos += Y,
stream.hmdOverride = null,
Z.trim()
}
,
y += " hmd-escape-backslash",
stream.pos -= Y,
y
}
if (!y.trim() && config.table) {
let X = !1;
if ("|" === U.charAt(0) && (stream.pos = stream.start + 1,
U = "|",
X = !0),
!_ && state.prevLine && state.prevLine.stream && state.prevLine.stream.string.trim() && !state.wasHeading && (X = !1),
X) {
if (!_) {
PN.test(stream.string) ? _ = TableType.SIMPLE : FN.test(stream.string) && (_ = TableType.NORMAL);
let $: string[] | undefined = void 0;
if (_) {
let Q = stream.lookAhead(1);
if (_ === TableType.NORMAL ? FN.test(Q) ? Q = Q.replace(/^\s*\|/, "").replace(/\|\s*$/, "") : _ = TableType.NONE : _ === TableType.SIMPLE && (PN.test(Q) || (_ = TableType.NONE)),
_) {
$ = Q.split("|");
for (let J = 0; J < $.length; J++) {
let ee = $[J];
if (BN.test(ee))
ee = "right";
else if (NN.test(ee))
ee = "left";
else if (RN.test(ee))
ee = "center";
else {
if (!HN.test(ee)) {
_ = TableType.NONE;
break
}
ee = "default"
}
$[J] = ee
}
}
}
_ && (state.hmdTable = _,
state.hmdTableColumns = $!,
"rtl" === readSide(stream.string) && (state.hmdTableRTL = !0),
state.hmdTableRow = state.hmdTableCol = 0)
}
if (_) {
let te = state.hmdTableColumns.length - 1
, ne = state.hmdTableCol
, ee = state.hmdTableRow;
0 == ne && (y += " line-HyperMD-table-".concat(_.toString(), " line-HyperMD-table-row line-HyperMD-table-row-").concat(ee.toString()),
state.hmdTableRTL && (y += " line-HyperMD-table-rtl")),
_ === TableType.NORMAL && (0 === state.hmdTableCol && /^\s*\|$/.test(stream.string.slice(0, stream.pos)) || stream.match(/^\s*$/, !1)) ? y += " hmd-table-sep hmd-table-sep-dummy" : state.hmdTableCol < te && (y += " hmd-table-sep hmd-table-sep-".concat(ne.toString()),
state.hmdTableCol += 1)
}
}
}
if (_ && 1 === state.hmdTableRow && y.includes("emoji") && (y = ""),
g && "<" === U) {
let ie = null;
if ("!" === stream.peek() && stream.match(/^\![A-Z]+/) ? ie = ">" : "!" === stream.peek() && stream.match("![CDATA[") ? ie = "]]>" : "?" === stream.peek() && (ie = "?>"),
null != ie)
return enterMode(stream, state, null, {
endTag: ie,
style: (y + " comment hmd-cdata-html").trim()
})
}
if (config.hashtag && g)
if (state.hmdHashtag) {
let re = !1;
if (!(y = y.replace(/((formatting )?formatting-em|em) /g, "")).includes("formatting") && !/^\s*$/.test(U)) {
d = U.match(TN);
let oe = U.length - (d ? d[0].length : 0);
oe > 0 && (stream.backUp(oe),
re = !0)
}
if (re || (re = stream.eol()),
re || (re = !TN.test(stream.peek()!)),
re)
{
let le = stream.current();
y += " hashtag-end " + (le = "tag-" + le.replace(/[^_a-zA-Z0-9\-]/g, "")),
state.hmdHashtag = !1
}
} else if ("#" === U && !state.linkText && !state.image && (h || /^\s*$/.test(stream.string.charAt(stream.start - 1)))) {
let ae = stream.string.slice(stream.pos).replace(/\\./g, "")
, se = TN.exec(ae);
if (se && /[^0-9]/.test(se[0])) {
let le = "tag-" + se[0].replace(/[^_a-zA-Z0-9\-]/g, "");
state.hmdHashtag = !0,
y += " formatting formatting-hashtag hashtag-begin " + config.tokenTypeOverrides.hashtag + " " + le
}
}
}
return y.trim() || null;
},
}
}, 'd-any');

75
shared/markdown.util.ts Normal file
View File

@@ -0,0 +1,75 @@
import type { Root, RootContent } from "hast";
import { dom, styling, text, type Class, type Node } from "./dom.util";
import prose, { tag, a, blockquote, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout, type Prose } from "./proses";
import { heading } from "hast-util-heading";
import { headingRank } from "hast-util-heading-rank";
import { parseId } from "./general.util";
import { loading } from "#shared/proses";
export function renderMarkdown(markdown: Root, proses: Record<string, Prose>): HTMLDivElement
{
return dom('div', {}, markdown.children.map(e => renderContent(e, proses)));
}
function renderContent(node: RootContent, proses: Record<string, Prose>): Node
{
if(node.type === 'text' && node.value.length > 0 && node.value !== '\n')
{
return text(node.value);
}
else if(node.type === 'comment' && node.value.length > 0 && node.value !== '\n')
{
return undefined;
}
else if(node.type === 'element')
{
const children = node.children.map(e => renderContent(e, proses)), properties = { ...node.properties, class: node.properties.className as string | string[] };
if(node.tagName in proses)
return prose(node.tagName, proses[node.tagName], children, properties);
else
return dom(node.tagName as keyof HTMLElementTagNameMap, properties, children);
}
return undefined;
}
export interface MDProperties
{
class?: Class;
style?: string | Record<string, string>;
tags?: Record<string, Prose>;
}
export default function(content: string, filter?: string, properties?: MDProperties): HTMLElement
{
const load = loading('normal');
queueMicrotask(() => {
useMarkdown().parse(content).then(data => {
if(filter)
{
const start = data?.children.findIndex(e => heading(e) && parseId(e.properties.id as string | undefined) === filter) ?? -1;
if(start !== -1)
{
let end = start;
const rank = headingRank(data.children[start])!;
while(end < data.children.length)
{
end++;
if(heading(data.children[end]) && headingRank(data.children[end])! <= rank)
break;
}
data = { ...data, children: data.children.slice(start, end) };
}
}
const el = renderMarkdown(data, { tag, a, blockquote, callout, h1, h2, h3, h4, h5, hr, li, small, table, td, th, ...properties?.tags });
if(properties) styling(el, properties);
load.parentElement?.replaceChild(el, load);
});
})
return load;
}

View File

@@ -1,6 +1,6 @@
import type { CanvasContent, CanvasNode } from "~/types/canvas";
import type { CanvasPreferences } from "~/types/general";
import type { Position, Box, Direction } from "./canvas.util";
import type { Position, Box, Direction, CanvasEditor } from "./canvas.util";
interface SnapPoint {
pos: Position;
@@ -26,8 +26,20 @@ const enum TYPE {
EDGE,
}
class SpatialGrid {
private cells: Map<number, Map<number, Set<string>>> = new Map();
export function overlapsBoxes(a: Box, b: Box): boolean
{
return overlaps(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height);
}
export function overlaps(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean
{
return !(bx > (ax + aw)
|| (bx + bw) < ax
|| by > (ay + ah)
|| (by + bh) < ay);
}
export class SpatialGrid<T extends Box> {
private cells: Map<number, Map<number, Set<T>>> = new Map();
private cellSize: number;
private minx: number = Infinity;
@@ -35,20 +47,23 @@ class SpatialGrid {
private maxx: number = -Infinity;
private maxy: number = -Infinity;
private cacheSet: Set<string> = new Set<string>();
private cacheSet: Set<T> = new Set<T>();
constructor(cellSize: number) {
constructor(cellSize: number)
{
this.cellSize = cellSize;
}
private updateBorders(startX: number, startY: number, endX: number, endY: number) {
private updateBorders(startX: number, startY: number, endX: number, endY: number)
{
this.minx = Math.min(this.minx, startX);
this.miny = Math.min(this.miny, startY);
this.maxx = Math.max(this.maxx, endX);
this.maxy = Math.max(this.maxy, endY);
}
insert(node: CanvasNode): void {
insert(node: T): void
{
const startX = Math.floor(node.x / this.cellSize);
const startY = Math.floor(node.y / this.cellSize);
const endX = Math.ceil((node.x + node.width) / this.cellSize);
@@ -59,22 +74,22 @@ class SpatialGrid {
for (let i = startX; i <= endX; i++) {
let gridX = this.cells.get(i);
if (!gridX) {
gridX = new Map<number, Set<string>>();
gridX = new Map<number, Set<T>>();
this.cells.set(i, gridX);
}
for (let j = startY; j <= endY; j++) {
let gridY = gridX.get(j);
if (!gridY) {
gridY = new Set<string>();
gridY = new Set<T>();
gridX.set(j, gridY);
}
gridY.add(node.id);
gridY.add(node);
}
}
}
remove(node: CanvasNode): void {
remove(node: T): void {
const startX = Math.floor(node.x / this.cellSize);
const startY = Math.floor(node.y / this.cellSize);
const endX = Math.ceil((node.x + node.width) / this.cellSize);
@@ -84,17 +99,17 @@ class SpatialGrid {
const gridX = this.cells.get(i);
if (gridX) {
for (let j = startY; j <= endY; j++) {
gridX.get(j)?.delete(node.id);
gridX.get(j)?.delete(node);
}
}
}
}
fetch(x: number, y: number): Set<string> | undefined {
fetch(x: number, y: number): Set<T> | undefined {
return this.query(x, y, x, y);
}
query(x1: number, y1: number, x2: number, y2: number): Set<string> {
query(x1: number, y1: number, x2: number, y2: number): Set<T> {
this.cacheSet.clear();
const startX = Math.floor(x1 / this.cellSize);
@@ -108,7 +123,7 @@ class SpatialGrid {
for (let dy = startY; dy <= endY; dy++) {
const cellNodes = gridX.get(dy);
if (cellNodes) {
cellNodes.forEach(neighbor => this.cacheSet.add(neighbor));
cellNodes.forEach(neighbor => !this.cacheSet.has(neighbor) && (overlaps(x1, y1, x2 - x1, y2 - y1, neighbor.x, neighbor.y, neighbor.width, neighbor.height)) && this.cacheSet.add(neighbor));
}
}
}
@@ -117,7 +132,7 @@ class SpatialGrid {
return this.cacheSet;
}
getViewportNeighbors(node: CanvasNode, viewport?: Box): Set<string> {
getViewportNeighbors(node: T, viewport?: Box): Set<T> {
this.cacheSet.clear();
const startX = Math.floor(node.x / this.cellSize);
@@ -127,8 +142,8 @@ class SpatialGrid {
const minX = Math.max(viewport ? Math.max(this.minx, Math.floor(viewport.x / this.cellSize)) : this.minx, startX - 8);
const minY = Math.max(viewport ? Math.max(this.miny, Math.floor(viewport.y / this.cellSize)) : this.miny, startY - 8);
const maxX = Math.min(viewport ? Math.min(this.maxx, Math.ceil((viewport.x + viewport.w) / this.cellSize)) : this.maxx, endX + 8);
const maxY = Math.min(viewport ? Math.min(this.maxy, Math.ceil((viewport.y + viewport.h) / this.cellSize)) : this.maxy, endY + 8);
const maxX = Math.min(viewport ? Math.min(this.maxx, Math.ceil((viewport.x + viewport.width) / this.cellSize)) : this.maxx, endX + 8);
const maxY = Math.min(viewport ? Math.min(this.maxy, Math.ceil((viewport.y + viewport.height) / this.cellSize)) : this.maxy, endY + 8);
for (let dx = minX; dx <= maxX; dx++) {
const gridX = this.cells.get(dx);
@@ -137,7 +152,7 @@ class SpatialGrid {
const cellNodes = gridX.get(dy);
if (cellNodes) {
cellNodes.forEach(neighbor => {
if (neighbor !== node.id) this.cacheSet.add(neighbor);
if (neighbor !== node) this.cacheSet.add(neighbor);
});
}
}
@@ -150,7 +165,7 @@ class SpatialGrid {
const cellNodes = gridX.get(dy);
if (cellNodes) {
cellNodes.forEach(neighbor => {
if (neighbor !== node.id) this.cacheSet.add(neighbor);
if (neighbor !== node) this.cacheSet.add(neighbor);
});
}
}
@@ -200,34 +215,33 @@ class SnapPointCache {
}
export class SnapFinder {
private spatialGrid: SpatialGrid;
private spatialGrid: SpatialGrid<CanvasNode>;
private snapPointCache: SnapPointCache;
config: SnapConfig;
private config: SnapConfig;
hints: Ref<SnapHint[]>;
viewport: Ref<Box>;
private canvas: CanvasEditor;
private hints: SnapHint[] = [];
constructor(hints: Ref<SnapHint[]>, viewport: Ref<Box>, config: SnapConfig) {
constructor(canvas: CanvasEditor, config: SnapConfig) {
this.spatialGrid = new SpatialGrid(config.cellSize);
this.snapPointCache = new SnapPointCache();
this.config = config;
this.hints = hints;
this.viewport = viewport;
this.canvas = canvas;
}
add(node: CanvasNode): void
{
this.spatialGrid.insert(node);
this.snapPointCache.insert(node);
this.hints.value.length = 0;
this.hints.length = 0;
}
remove(node: CanvasNode): void
{
this.spatialGrid.remove(node);
this.snapPointCache.invalidate(node);
this.hints.value.length = 0;
this.hints.length = 0;
}
update(node: CanvasNode): void
@@ -257,26 +271,26 @@ export class SnapFinder {
const result: Partial<Box> = {
x: undefined,
y: undefined,
w: undefined,
h: undefined,
width: undefined,
height: undefined,
};
if(!this.config.preferences.neighborSnap)
{
result.x = this.snapToGrid(node.x);
result.w = this.snapToGrid(node.width);
result.width = this.snapToGrid(node.width);
result.y = this.snapToGrid(node.y);
result.h = this.snapToGrid(node.height);
result.height = this.snapToGrid(node.height);
return result;
}
this.hints.value.length = 0;
this.hints.length = 0;
this.snapPointCache.invalidate(node);
this.snapPointCache.insert(node);
const neighbors = [...this.spatialGrid.getViewportNeighbors(node, this.viewport.value)].flatMap(e => this.snapPointCache.getSnapPoints(e)).filter(e => !!e);
const neighbors = [...this.spatialGrid.getViewportNeighbors(node, this.canvas.viewport)].flatMap(e => this.snapPointCache.getSnapPoints(e)).filter(e => !!e);
const bestSnap = this.findBestSnap(this.snapPointCache.getSnapPoints(node.id)!, neighbors, this.config.threshold, resizeHandle);
return this.applySnap(node, bestSnap.x, bestSnap.y, resizeHandle);
@@ -323,7 +337,7 @@ export class SnapFinder {
yHints.forEach(e => e.start.x += bestSnap.x!);
}
this.hints.value = [...xHints, ...yHints];
this.hints = [...xHints, ...yHints];
return bestSnap;
}
@@ -335,14 +349,14 @@ export class SnapFinder {
private applySnap(node: CanvasNode, offsetx?: number, offsety?: number, resizeHandle?: Box): Partial<Box>
{
const result: Partial<Box> = { x: undefined, y: undefined, w: undefined, h: undefined };
const result: Partial<Box> = { x: undefined, y: undefined, width: undefined, height: undefined };
if (resizeHandle)
{
result.x = offsetx ? node.x + offsetx * resizeHandle.x : this.snapToGrid(node.x);
result.w = offsetx ? node.width + offsetx * resizeHandle.w : this.snapToGrid(node.width);
result.w = offsetx ? node.width + offsetx * resizeHandle.width : this.snapToGrid(node.width);
result.y = offsety ? node.y + offsety * resizeHandle.y : this.snapToGrid(node.y);
result.h = offsety ? node.height - offsety * resizeHandle.h : this.snapToGrid(node.height);
result.h = offsety ? node.height - offsety * resizeHandle.height : this.snapToGrid(node.height);
}
else
{

262
shared/proses.ts Normal file
View File

@@ -0,0 +1,262 @@
import { dom, icon, type NodeChildren, type Node, type NodeProperties, type Class, mergeClasses } from "#shared/dom.util";
import { parseURL } from 'ufo';
import render from "#shared/markdown.util";
import { popper } from "#shared/floating.util";
import { Canvas } from "#shared/canvas.util";
import { Content, iconByType, type LocalContent } from "#shared/content.util";
import type { RouteLocationAsRelativeTyped, RouteMapGeneric } from "vue-router";
import { unifySlug } from "#shared/general.util";
export type CustomProse = (properties: any, children: NodeChildren) => Node;
export type Prose = { class: string } | { custom: CustomProse };
export const tag: Prose = {
custom(properties, children) {
const tag = properties.tag as string;
const el = dom('span', { class: "before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30" }, children);
const overview = Content.getFromPath('tags');
let rendered = false;
if(!!overview)
{
popper(el, {
arrow: true,
delay: 150,
offset: 10,
placement: 'bottom-start',
class: 'data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 data-[state=open]:transition-transform text-light-100 dark:text-dark-100 max-w-[600px] max-h-[600px] w-full z-[45]',
content: [loading("large")],
onShow(content: HTMLDivElement) {
if(!rendered)
{
Content.getContent(overview.id).then((_content) => {
content.replaceChild(render((_content as LocalContent<'markdown'>).content ?? '', tag, { class: 'max-w-[600px] max-h-[600px] w-full overflow-auto py-4 px-6' }), content.children[0]);
});
rendered = true;
}
},
});
}
return el;
}
}
export const a: Prose = {
custom(properties, children) {
const href = properties.href as string;
const { hash, pathname } = parseURL(href);
const router = useRouter();
const overview = Content.getFromPath(pathname === '' && hash.length > 0 ? unifySlug(router.currentRoute.value.params.path) : pathname);
const link = overview ? { name: 'explore-path', params: { path: overview.path }, hash: hash } : href, nav = router.resolve(link);
let rendered = false;
const el = dom('a', { class: 'text-accent-blue inline-flex items-center', attributes: { href: nav.href }, listeners: {
'click': (e) => {
e.preventDefault();
router.push(link);
}
}}, [
dom('span', {}, [
...(children ?? []),
overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
])
]);
if(!!overview)
{
popper(el, {
arrow: true,
delay: 150,
offset: 12,
placement: 'bottom-start',
class: 'data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 data-[state=open]:transition-transform text-light-100 dark:text-dark-100 min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full z-[45]',
content: [loading("large")],
onShow(content: HTMLDivElement) {
if(!rendered)
{
Content.getContent(overview.id).then((_content) => {
if(_content?.type === 'markdown')
{
content.replaceChild(render((_content as LocalContent<'markdown'>).content ?? '', hash.length > 0 ? hash.substring(1) : undefined, { class: 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full overflow-auto py-4 px-6' }), content.children[0]);
}
if(_content?.type === 'canvas')
{
const canvas = new Canvas((_content as LocalContent<'canvas'>).content);
content.replaceChild(dom('div', { class: 'w-[600px] h-[600px] relative' }, [canvas.container]), content.children[0]);
canvas.mount();
}
});
rendered = true;
}
},
});
}
return el;
}
}
export const fakeA: Prose = {
custom(properties, children) {
const href = properties.href as string;
const { hash, pathname } = parseURL(href);
const router = useRouter();
const overview = Content.getFromPath(pathname === '' && hash.length > 0 ? unifySlug(router.currentRoute.value.params.path) : pathname);
const el = dom('span', { class: 'cursor-pointer text-accent-blue inline-flex items-center' }, [
dom('span', {}, [
...(children ?? []),
overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
])
]);
if(!!overview)
{
const magicKeys = useMagicKeys();
popper(el, {
arrow: true,
delay: 150,
offset: 12,
placement: 'bottom-start',
class: 'data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 data-[state=open]:transition-transform text-light-100 dark:text-dark-100 min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full z-[45]',
content: [loading("large")],
onShow(content: HTMLDivElement) {
if(!magicKeys.current.has('control') || magicKeys.current.has('meta'))
return false;
content.replaceChild(loading("large"), content.children[0]);
Content.getContent(overview.id).then((_content) => {
if(_content?.type === 'markdown')
{
content.replaceChild(render((_content as LocalContent<'markdown'>).content ?? '', hash.length > 0 ? hash.substring(1) : undefined, { class: 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full overflow-auto py-4 px-6' }), content.children[0]);
}
if(_content?.type === 'canvas')
{
const canvas = new Canvas((_content as LocalContent<'canvas'>).content);
content.replaceChild(dom('div', { class: 'w-[600px] h-[600px] relative' }, [canvas.container]), content.children[0]);
canvas.mount();
}
});
},
});
}
return el;
}
}
export const callout: Prose = {
custom(properties, children) {
const calloutIconByType: Record<string, string> = {
note: 'radix-icons:pencil-1',
abstract: 'radix-icons:file-text',
info: 'radix-icons:info-circled',
todo: 'radix-icons:check-circled',
tip: 'radix-icons:star',
success: 'radix-icons:check',
question: 'radix-icons:question-mark-circled',
warning: 'radix-icons:exclamation-triangle',
failure: 'radix-icons:cross-circled',
danger: 'radix-icons:circle-backslash',
bug: 'solar:bug-linear',
example: 'radix-icons:list-bullet',
quote: 'radix-icons:quote',
};
const defaultCalloutIcon = 'radix-icons:info-circled';
const { type, title, fold }: {
type: string;
title?: string;
fold?: boolean;
} = properties;
let open = fold;
const trigger = dom('div', { class: [{'cursor-pointer': fold !== undefined}, 'flex flex-row items-center justify-start ps-2'] }, [icon(calloutIconByType[type] ?? defaultCalloutIcon, { inline: true, width: 24, height: 24, class: 'w-6 h-6 stroke-2 float-start me-2 flex-shrink-0' }), !!title ? dom('span', { class: 'block font-bold text-start', text: title }) : undefined, fold !== undefined ? icon('radix-icons:caret-right', { height: 24, width: 24, class: 'transition-transform group-data-[state=open]:rotate-90 w-6 h-6 mx-6' }) : undefined]);
const container = dom('div', { class: 'callout group overflow-hidden my-4 p-3 ps-4 bg-blend-lighten !bg-opacity-25 border-l-4 inline-block pe-8 bg-light-blue dark:bg-dark-blue', attributes: { 'data-state': fold !== false ? 'closed' : 'open', 'data-type': type } }, [
trigger,
dom('div', { class: 'overflow-hidden group-data-[state=closed]:animate-[collapseClose_0.2s_ease-in-out] group-data-[state=open]:animate-[collapseOpen_0.2s_ease-in-out] group-data-[state=closed]:h-0' }, [
dom('div', { class: 'px-2' }, children),
])
]);
trigger.addEventListener('click', e => {
container.setAttribute('data-state', open ? 'open' : 'closed');
open = !open;
})
return container;
},
}
export const blockquote: Prose = {
class: 'empty:before:hidden ps-4 my-4 relative before:absolute before:-top-1 before:-bottom-1 before:left-0 before:w-1 before:bg-light-30 dark:before:bg-dark-30',
}
export const h1: Prose = {
class: 'text-5xl font-thin mt-3 mb-8 first:pt-0 pt-2',
}
export const h2: Prose = {
class: 'text-4xl font-semibold mt-3 mb-6 ms-1 first:pt-0 pt-2',
}
export const h3: Prose = {
class: 'text-2xl font-bold mt-2 mb-4',
}
export const h4: Prose = {
class: 'text-xl font-semibold my-2',
}
export const h5: Prose = {
class: 'text-lg font-semibold my-1',
}
export const hr: Prose = {
class: 'border-b border-light-35 dark:border-dark-35 m-4',
}
export const li: Prose = {
class: 'before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4',
}
export const small: Prose = {
class: 'text-light-60 dark:text-dark-60 text-sm italic',
}
export const table: Prose = {
class: 'mx-4 my-8 border-collapse border border-light-35 dark:border-dark-35',
}
export const td: Prose = {
class: 'border border-light-35 dark:border-dark-35 py-1 px-2',
}
export const th: Prose = {
class: 'border border-light-35 dark:border-dark-35 px-4 first:pt-0',
}
export default function(tag: string, prose: Prose, children?: NodeChildren, properties?: any): Node
{
if('class' in prose)
{
return dom(tag as keyof HTMLElementTagNameMap, { class: [properties?.class, prose.class] }, children ?? []);
}
else
{
return prose.custom(properties, children ?? []);
}
}
export function link(properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped<RouteMapGeneric, string>, children?: NodeChildren)
{
const router = useRouter();
const nav = link ? router.resolve(link) : undefined;
return dom('a', { ...properties, class: [properties?.class, properties?.active && router.currentRoute.value.fullPath === nav?.fullPath ? properties.active : undefined], attributes: { href: nav?.href, 'data-active': properties?.active ? mergeClasses(properties?.active) : undefined }, listeners: link ? {
click: function(e)
{
e.preventDefault();
router.push(link);
}
} : undefined }, children);
}
export function loading(size: 'small' | 'normal' | 'large' = 'normal'): HTMLElement
{
return dom('span', { class: ["after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin", {'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}] })
}
export function button(content: Node, onClick?: () => void, cls?: Class)
{
return dom('button', { class: [`text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50`, cls], listeners: { click: onClick } }, [ content ]);
}

File diff suppressed because it is too large Load Diff

193
shared/tree.ts Normal file
View File

@@ -0,0 +1,193 @@
import { Content, type LocalContent } from "./content.util";
import { dom } from "./dom.util";
import { clamp } from "./general.util";
export type Recursive<T> = T & {
children?: T[];
parent?: T;
};
export class Tree<T extends Omit<LocalContent, 'content'>>
{
private _data: Recursive<T>[];
private _flatten: T[];
constructor(data: T[])
{
this._data = data;
this._flatten = this.accumulate(e => e);
}
remove(id: string)
{
const recursive = (data?: Recursive<T>[], parent?: T) => data?.filter(e => e.id !== id)?.map((e, i) => {
e.order = i;
e.children = recursive(e.children as T[], e);
return e;
});
this._data = recursive(this._data)!;
this._flatten = this.accumulate(e => e);
}
insertAt(item: Recursive<T>, pos: number)
{
const parent = item.parent ? item.parent.id : undefined;
const recursive = (data?: Recursive<T>[]) => data?.flatMap(e => {
if(e.id === parent)
{
e.children = e.children ?? [];
e.children.splice(clamp(pos, 0, e.children.length), 0, item);
}
else if(e.type === 'folder')
e.children = recursive(e.children as T[]);
return e;
}).map((e, i) => { e.order = i; return e; });
if(!parent || parent === '')
{
this._data.splice(pos, 0, item);
this._data.forEach((e, i) => e.order = i);
}
else
this._data = recursive(this._data)!;
this._flatten = this.accumulate(e => e);
}
find(id: string): T | undefined
{
const recursive = (data?: Recursive<T>[]): T | undefined => {
if(!data)
return;
for(const e of data)
{
if(e.id === id)
return e;
const result = recursive(e.children as T[]);
if(result)
return result;
}
};
return recursive(this._data);
}
search(prop: keyof T, value: string): T[]
{
const recursive = (data?: Recursive<T>[]): T[] => {
if(!data)
return [];
const arr = [];
for(const e of data)
{
if(e[prop] === value)
arr.push(e);
else
arr.push(...recursive(e.children as T[]));
}
return arr;
};
return recursive(this._data);
}
subset(id: string): Tree<T> | undefined
{
const subset = this.find(id);
return subset ? new Tree([subset]) : undefined;
}
each(callback: (item: T, depth: number, parent?: T) => void)
{
const recursive = (depth: number, data?: Recursive<T>[], parent?: T) => data?.forEach(e => { callback(e, depth, parent); recursive(depth + 1, e.children as T[], e) });
recursive(1, this._data);
}
accumulate(callback: (item: T, depth: number, parent?: T) => any): any[]
{
const recursive = (depth: number, data?: Recursive<T>[], parent?: T): any[] => data?.flatMap(e => [callback(e, depth, parent), ...recursive(depth + 1, e.children as T[], e)]) ?? [];
return recursive(1, this._data);
}
get data()
{
return this._data;
}
get flatten()
{
return this._flatten;
}
}
export class TreeDOM
{
container: HTMLElement;
tree: Tree<Omit<LocalContent & { element?: HTMLElement }, "content">>;
private filter?: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => boolean | undefined;
private folder: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement;
private leaf: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement;
constructor(folder: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement, leaf: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement, filter?: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => boolean | undefined)
{
this.tree = new Tree(Content.tree);
this.filter = filter;
this.folder = folder;
this.leaf = leaf;
const elements = this.tree.accumulate(this.render.bind(this));
this.container = dom('div', { class: 'list-none select-none text-light-100 dark:text-dark-100 text-sm ps-2' }, elements);
}
render(item: Recursive<Omit<LocalContent & { element?: HTMLElement }, "content">>, depth: number): HTMLElement | undefined
{
if(this.filter && !(this.filter(item, depth) ?? true))
return;
if(item.type === 'folder')
{
let folded = false;
if(item.element)
folded = item.element.getAttribute('data-state') === 'open';
item.element = this.folder(item, depth);
if(!!item.parent) item.element.classList.toggle('hidden', item.parent.element!.getAttribute('data-state') === 'closed' || item.parent.element!.classList.contains('hidden'));
item.element.setAttribute('data-state', folded ? 'open' : 'closed');
item.element.addEventListener('click', () => this.toggle(item));
return item.element;
}
else
{
item.element = this.leaf(item, depth);
if(!!item.parent) item.element.classList.toggle('hidden', item.parent.element!.getAttribute('data-state') === 'closed' || item.parent.element!.classList.contains('hidden'));
return item.element;
}
}
update()
{
this.container.replaceChildren(...this.tree.flatten.map(e => e.element!));
}
toggle(item?: Omit<LocalContent & { element?: HTMLElement }, 'content'>, state?: boolean)
{
if(item && item.type === 'folder')
{
const open = state ?? item.element!.getAttribute('data-state') !== 'open';
item.element!.setAttribute('data-state', open ? 'open' : 'closed');
new Tree([item]).each((e, _, parent) => {
if(!parent)
return;
e.element!.classList.toggle('hidden', !this.opened(parent) || parent.element!.classList.contains('hidden'));
});
}
}
opened(item?: Omit<LocalContent & { element?: HTMLElement }, 'content'>): boolean | undefined
{
return item ? item.element!.getAttribute('data-state') === 'open' : undefined;
}
}

12
types/auth.d.ts vendored
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';
declare module 'vue-router'
@@ -13,8 +14,8 @@ declare module 'vue-router'
}
}
import 'nuxt';
declare module 'nuxt'
import '@nuxt/schema';
declare module '@nuxt/schema'
{
interface RuntimeConfig
{
@@ -30,9 +31,8 @@ export interface UserRawData {
}
export interface UserExtendedData {
signin: string;
lastTimestamp: string;
logCount: number;
signin: Date;
lastTimestamp: Date;
}
export type Permissions = { permissions: string[] };

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