You've already forked obsidian-visualiser
Compare commits
97 Commits
rework
...
4885479ac6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4885479ac6 | ||
|
|
fd0603f916 | ||
| 0771d5ebd1 | |||
|
|
598cf54bc5 | ||
|
|
9352b3f0a1 | ||
|
|
a30f394ef7 | ||
|
|
32439b41f6 | ||
|
|
b8f547d3e9 | ||
|
|
3c412d1cbe | ||
|
|
1de2439a8a | ||
|
|
308c2974f1 | ||
|
|
cb00c093ff | ||
|
|
735dfb6980 | ||
| f599b561af | |||
| 7beeed8a61 | |||
| 403a65158a | |||
| fef7c8f57c | |||
| e5b53585aa | |||
| e7d0d69e55 | |||
| f2d00097d6 | |||
| 0b97e9a295 | |||
| 6abc467a43 | |||
| 939b9cbd28 | |||
| e2c18ff406 | |||
| 154584e175 | |||
| af317cb0e3 | |||
| 8fc1855ae6 | |||
| f3c453b1b2 | |||
| 62b2f3bbfb | |||
| 0b1809c3f6 | |||
| 3f04bb3d0c | |||
| 685bd47fc4 | |||
| f32c51ca38 | |||
| 348c991c54 | |||
| 76db788192 | |||
| 4433cf0e00 | |||
| 9439dd2d95 | |||
| 823f3d7730 | |||
| 62950be032 | |||
| b1a9eb859e | |||
| 83ac9b1f36 | |||
| 7403515f80 | |||
| 3839b003dc | |||
| e7412f6768 | |||
| 6f305397a8 | |||
| 896af11fa7 | |||
| 9515132659 | |||
| 031a51c2fe | |||
| 7bdf6ccd13 | |||
| cb2c19fada | |||
| 0abf0b11e6 | |||
| ec0afa9686 | |||
| b24a083d2e | |||
| ad61dc8897 | |||
| 1e8afe90dd | |||
| 8439d3444f | |||
| 36909c5d66 | |||
| fea37e2f59 | |||
| a3d9e466a5 | |||
| 9c69ff2903 | |||
| 3b919075ef | |||
| 4150b69ba3 | |||
| 298f47a280 | |||
| 161f0d856a | |||
| 51a5d501be | |||
| ecdfa947ac | |||
| fd951c294f | |||
| 602b0af212 | |||
| f7094f7ce1 | |||
| 429f1d4b38 | |||
| 5062d52667 | |||
| c4bf95e48b | |||
| 7fc7998a4b | |||
| fdaf765e2d | |||
| e99a5f15b4 | |||
| 5fb708051b | |||
| 9a69a92ef8 | |||
| f22e63bd4d | |||
| e83d8e802f | |||
| 3e463ea286 | |||
| 4125cbb3a2 | |||
| 4df9297d47 | |||
| d71e8b7910 | |||
| 20ab51a66c | |||
| 2855d4ba2e | |||
| 4f2fc31695 | |||
| 6e7243982b | |||
| 9c52494f8e | |||
| d0de943df2 | |||
| 1c239f161b | |||
| a9363e8c06 | |||
| d708e9ceb6 | |||
| 0c17dbf7bc | |||
| ac17134b7e | |||
| adb37b255a | |||
| b54402fc19 | |||
| 0882eb1dd0 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
bun.lockb
|
||||||
|
db.sqlite
|
||||||
|
db.sqlite-wal
|
||||||
|
db.sqlite-shm
|
||||||
9
app.vue
9
app.vue
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
|
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
|
||||||
<NuxtRouteAnnouncer/>
|
<NuxtRouteAnnouncer/>
|
||||||
|
<NuxtLoadingIndicator />
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<div class="xl:ps-12 xl:pe-12 ps-6 pe-4 flex flex-1 justify-center overflow-auto max-h-full relative">
|
<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">
|
||||||
<NuxtPage></NuxtPage>
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
<Toaster v-model="list" />
|
<Toaster v-model="list" />
|
||||||
@@ -38,4 +39,8 @@ const { list } = useToast();
|
|||||||
@apply bg-light-50;
|
@apply bg-light-50;
|
||||||
@apply dark:bg-dark-50;
|
@apply dark:bg-dark-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
931
components/CanvasEditor.vue
Normal file
931
components/CanvasEditor.vue
Normal file
@@ -0,0 +1,931 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { bezier, getBbox, opposite, posFromDir, rotation, type Box, type Direction, type Path, type Position } from '#shared/canvas.util';
|
||||||
|
import type CanvasNodeEditor from './canvas/CanvasNodeEditor.vue';
|
||||||
|
import type CanvasEdgeEditor from './canvas/CanvasEdgeEditor.vue';
|
||||||
|
import { SnapFinder, type SnapHint } from '#shared/physics.util';
|
||||||
|
import type { CanvasPreferences } from '~/types/general';
|
||||||
|
export type Element = { type: 'node' | 'edge', id: string };
|
||||||
|
|
||||||
|
interface ActionMap {
|
||||||
|
remove: CanvasNode | CanvasEdge | undefined;
|
||||||
|
create: CanvasNode | CanvasEdge | undefined;
|
||||||
|
property: CanvasNode | CanvasEdge;
|
||||||
|
}
|
||||||
|
type Action = keyof ActionMap;
|
||||||
|
interface HistoryEvent<T extends Action = Action>
|
||||||
|
{
|
||||||
|
event: T;
|
||||||
|
actions: HistoryAction<T>[];
|
||||||
|
}
|
||||||
|
interface HistoryAction<T extends Action>
|
||||||
|
{
|
||||||
|
element: Element;
|
||||||
|
from: ActionMap[T];
|
||||||
|
to: ActionMap[T];
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeEditor = InstanceType<typeof CanvasNodeEditor>;
|
||||||
|
type EdgeEditor = InstanceType<typeof CanvasEdgeEditor>;
|
||||||
|
|
||||||
|
const cancelEvent = (e: Event) => e.preventDefault();
|
||||||
|
const stopPropagation = (e: Event) => e.stopImmediatePropagation();
|
||||||
|
function getID(length: number)
|
||||||
|
{
|
||||||
|
for (var id = [], i = 0; i < length; i++)
|
||||||
|
id.push((16 * Math.random() | 0).toString(16));
|
||||||
|
return id.join("");
|
||||||
|
}
|
||||||
|
function center(touches: TouchList): Position
|
||||||
|
{
|
||||||
|
const pos = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
for(const touch of touches)
|
||||||
|
{
|
||||||
|
pos.x += touch.clientX;
|
||||||
|
pos.y += touch.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos.x /= touches.length;
|
||||||
|
pos.y /= touches.length;
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
function distance(touches: TouchList): number
|
||||||
|
{
|
||||||
|
const [A, B] = touches;
|
||||||
|
return Math.hypot(B.clientX - A.clientX, B.clientY - A.clientY);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
import { clamp } from '#shared/general.util';
|
||||||
|
import type { CanvasContent, CanvasEdge, CanvasNode } from '~/types/canvas';
|
||||||
|
|
||||||
|
const canvas = defineModel<CanvasContent>({ required: true });
|
||||||
|
const props = defineProps<{
|
||||||
|
path: string,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5), spacing = ref<number | undefined>(32);
|
||||||
|
const focusing = ref<Element>(), editing = ref<Element>();
|
||||||
|
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef'), patternRef = useTemplateRef('patternRef'), toolbarRef = useTemplateRef('toolbarRef'), viewportSize = useElementBounding(canvasRef);
|
||||||
|
const nodes = useTemplateRef<NodeEditor[]>('nodes'), edges = useTemplateRef<EdgeEditor[]>('edges');
|
||||||
|
const canvasSettings = useCookie<CanvasPreferences>('canvasPreference', { default: () => ({ gridSnap: true, neighborSnap: true, spacing: 32 }) });
|
||||||
|
const hints = ref<SnapHint[]>([]);
|
||||||
|
const viewport = computed<Box>(() => {
|
||||||
|
const width = viewportSize.width.value / zoom.value, height = viewportSize.height.value / zoom.value;
|
||||||
|
const movementX = viewportSize.width.value - width, movementY = viewportSize.height.value - height;
|
||||||
|
return { x: -dispX.value + movementX / 2, y: -dispY.value + movementY / 2, w: width, h: height };
|
||||||
|
});
|
||||||
|
const updateScaleVar = useDebounceFn(() => {
|
||||||
|
if(transformRef.value)
|
||||||
|
{
|
||||||
|
console.log(zoom.value);
|
||||||
|
transformRef.value.style.setProperty('--tw-scale', zoom.value.toString());
|
||||||
|
}
|
||||||
|
if(canvasRef.value)
|
||||||
|
{
|
||||||
|
canvasRef.value.style.setProperty('--zoom-multiplier', (1 / Math.pow(zoom.value, 0.7)).toFixed(3));
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
type DragOrigin = { type: 'edge', id: string, destination: 'from' | 'to', node: string } | { type: 'node', id: string };
|
||||||
|
const fakeEdge = ref<{ from?: Position, fromSide?: Direction, to?: Position, toSide?: Direction, path?: Path, style?: { stroke: string, fill: string }, hex?: string, drag?: DragOrigin, snapped?: { node: string, side: Direction } }>({});
|
||||||
|
|
||||||
|
const focused = computed(() => focusing.value ? focusing.value?.type === 'node' ? nodes.value?.find(e => !!e && e.id === focusing.value!.id) : edges.value?.find(e => !!e && e.id === focusing.value!.id) : undefined), edited = computed(() => editing.value ? editing.value?.type === 'node' ? nodes.value?.find(e => !!e && e.id === editing.value!.id) : edges.value?.find(e => !!e && e.id === editing.value!.id) : undefined);
|
||||||
|
let snapFinder: SnapFinder;
|
||||||
|
|
||||||
|
const history = ref<HistoryEvent[]>([]);
|
||||||
|
const historyPos = ref(-1);
|
||||||
|
const historyCursor = computed(() => history.value.length > 0 && historyPos.value > -1 ? history.value[historyPos.value] : undefined);
|
||||||
|
|
||||||
|
watch(props, () => {
|
||||||
|
snapFinder = new SnapFinder(hints, viewport, { gridSize: 512, preferences: canvasSettings.value, threshold: 16, cellSize: 64 })
|
||||||
|
canvas.value.nodes?.forEach((e) => snapFinder.update(e));
|
||||||
|
focusing.value = undefined;
|
||||||
|
editing.value = undefined;
|
||||||
|
history.value = [];
|
||||||
|
historyPos.value = -1;
|
||||||
|
fakeEdge.value = {};
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(canvas, () => {
|
||||||
|
updateToolbarTransform();
|
||||||
|
}, { immediate: true, deep: true, });
|
||||||
|
|
||||||
|
const reset = (_: MouseEvent) => {
|
||||||
|
zoom.value = minZoom.value;
|
||||||
|
|
||||||
|
dispX.value = 0;
|
||||||
|
dispY.value = 0;
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAction<T extends Action = Action>(event: T, actions: HistoryAction<T>[])
|
||||||
|
{
|
||||||
|
historyPos.value++;
|
||||||
|
history.value.splice(historyPos.value, history.value.length - historyPos.value);
|
||||||
|
history.value[historyPos.value] = { event, actions };
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
let lastX = 0, lastY = 0, lastDistance = 0;
|
||||||
|
const dragMove = (e: MouseEvent) => {
|
||||||
|
dispX.value = dispX.value - (lastX - e.clientX) / zoom.value;
|
||||||
|
dispY.value = dispY.value - (lastY - e.clientY) / zoom.value;
|
||||||
|
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
};
|
||||||
|
const dragEnd = (e: MouseEvent) => {
|
||||||
|
window.removeEventListener('mouseup', dragEnd);
|
||||||
|
window.removeEventListener('mousemove', dragMove);
|
||||||
|
};
|
||||||
|
canvasRef.value?.addEventListener('mouseenter', () => {
|
||||||
|
window.addEventListener('wheel', cancelEvent, { passive: false });
|
||||||
|
document.addEventListener('gesturestart', cancelEvent);
|
||||||
|
document.addEventListener('gesturechange', cancelEvent);
|
||||||
|
|
||||||
|
canvasRef.value?.addEventListener('mouseleave', () => {
|
||||||
|
window.removeEventListener('wheel', cancelEvent);
|
||||||
|
document.removeEventListener('gesturestart', cancelEvent);
|
||||||
|
document.removeEventListener('gesturechange', cancelEvent);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
canvasRef.value?.addEventListener('mousedown', (e) => {
|
||||||
|
if(e.button === 1)
|
||||||
|
{
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', dragEnd, { passive: true });
|
||||||
|
window.addEventListener('mousemove', dragMove, { passive: true });
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
canvasRef.value?.addEventListener('wheel', (e) => {
|
||||||
|
if((zoom.value >= 3 && e.deltaY < 0) || (zoom.value <= minZoom.value && e.deltaY > 0))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const diff = Math.exp(e.deltaY * -0.001);
|
||||||
|
const centerX = (viewportSize.x.value + viewportSize.width.value / 2), centerY = (viewportSize.y.value + viewportSize.height.value / 2);
|
||||||
|
const mousex = centerX - e.clientX, mousey = centerY - e.clientY;
|
||||||
|
|
||||||
|
dispX.value = dispX.value - (mousex / (diff * zoom.value) - mousex / zoom.value);
|
||||||
|
dispY.value = dispY.value - (mousey / (diff * zoom.value) - mousey / zoom.value);
|
||||||
|
|
||||||
|
zoom.value = clamp(zoom.value * diff, minZoom.value, 3);
|
||||||
|
spacing.value = canvasSettings.value.gridSnap ? canvasSettings.value.spacing ?? 32 : undefined;
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
}, { passive: true });
|
||||||
|
canvasRef.value?.addEventListener('touchstart', (e) => {
|
||||||
|
({ x: lastX, y: lastY } = center(e.touches));
|
||||||
|
|
||||||
|
if(e.touches.length > 1)
|
||||||
|
{
|
||||||
|
lastDistance = distance(e.touches);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasRef.value?.addEventListener('touchend', touchend, { passive: true });
|
||||||
|
canvasRef.value?.addEventListener('touchcancel', touchcancel, { passive: true });
|
||||||
|
canvasRef.value?.addEventListener('touchmove', touchmove, { passive: true });
|
||||||
|
}, { passive: true });
|
||||||
|
const touchend = (e: TouchEvent) => {
|
||||||
|
if(e.touches.length > 1)
|
||||||
|
{
|
||||||
|
({ x: lastX, y: lastY } = center(e.touches));
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasRef.value?.removeEventListener('touchend', touchend);
|
||||||
|
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
|
||||||
|
canvasRef.value?.removeEventListener('touchmove', touchmove);
|
||||||
|
};
|
||||||
|
const touchcancel = (e: TouchEvent) => {
|
||||||
|
if(e.touches.length > 1)
|
||||||
|
{
|
||||||
|
({ x: lastX, y: lastY } = center(e.touches));
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasRef.value?.removeEventListener('touchend', touchend);
|
||||||
|
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
|
||||||
|
canvasRef.value?.removeEventListener('touchmove', touchmove);
|
||||||
|
};
|
||||||
|
const touchmove = (e: TouchEvent) => {
|
||||||
|
const pos = center(e.touches);
|
||||||
|
dispX.value = dispX.value - (lastX - pos.x) / zoom.value;
|
||||||
|
dispY.value = dispY.value - (lastY - pos.y) / zoom.value;
|
||||||
|
lastX = pos.x;
|
||||||
|
lastY = pos.y;
|
||||||
|
|
||||||
|
if(e.touches.length === 2)
|
||||||
|
{
|
||||||
|
const dist = distance(e.touches);
|
||||||
|
const diff = dist / lastDistance;
|
||||||
|
|
||||||
|
zoom.value = clamp(zoom.value * diff, minZoom.value, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateTransform()
|
||||||
|
{
|
||||||
|
if(transformRef.value)
|
||||||
|
{
|
||||||
|
transformRef.value.style.transform = `scale3d(${zoom.value}, ${zoom.value}, 1) translate3d(${dispX.value}px, ${dispY.value}px, 0)`;
|
||||||
|
updateScaleVar();
|
||||||
|
}
|
||||||
|
if(patternRef.value && canvasSettings.value.gridSnap)
|
||||||
|
{
|
||||||
|
patternRef.value.parentElement?.classList.remove('hidden');
|
||||||
|
patternRef.value.setAttribute("x", (viewportSize.width.value / 2 + dispX.value % spacing.value! * zoom.value).toFixed(3));
|
||||||
|
patternRef.value.setAttribute("y", (viewportSize.height.value / 2 + dispY.value % spacing.value! * zoom.value).toFixed(3));
|
||||||
|
patternRef.value.setAttribute("width", (zoom.value * spacing.value!).toFixed(3));
|
||||||
|
patternRef.value.setAttribute("height", (zoom.value * spacing.value!).toFixed(3));
|
||||||
|
|
||||||
|
patternRef.value.children[0].setAttribute('cx', (zoom.value).toFixed(3));
|
||||||
|
patternRef.value.children[0].setAttribute('cy', (zoom.value).toFixed(3));
|
||||||
|
patternRef.value.children[0].setAttribute('r', (zoom.value).toFixed(3));
|
||||||
|
}
|
||||||
|
else if(patternRef.value && !canvasSettings.value.gridSnap)
|
||||||
|
{
|
||||||
|
patternRef.value.parentElement?.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateToolbarTransform()
|
||||||
|
{
|
||||||
|
const offsetY = -12;
|
||||||
|
if(toolbarRef.value)
|
||||||
|
{
|
||||||
|
if(!focusing.value)
|
||||||
|
{
|
||||||
|
toolbarRef.value.style.transform = '';
|
||||||
|
}
|
||||||
|
else if(focusing.value.type === 'node')
|
||||||
|
{
|
||||||
|
const node = canvas.value.nodes!.find(e => e.id === focusing.value!.id)!;
|
||||||
|
toolbarRef.value.style.transform = `translate(${node.x}px, ${node.y}px) translateY(-100%) translateY(${offsetY}px) translateX(-50%) translateX(${node.width / 2}px) scale(calc(1 / var(--tw-scale)))`;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const path = edges.value!.find(e => e.id === focusing.value!.id)!.path;
|
||||||
|
const x = path.from.x + (path.to.x - path.from.x) / 2, y = path.from.y;
|
||||||
|
toolbarRef.value.style.transform = `translate(${x}px, ${y}px) translateY(-100%) translateY(${offsetY}px) translateX(-50%) scale(calc(1 / var(--tw-scale)))`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function moveNode(ids: string[], deltax: number, deltay: number)
|
||||||
|
{
|
||||||
|
if(ids.length === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const actions: HistoryAction<'property'>[] = [];
|
||||||
|
for(const id of ids)
|
||||||
|
{
|
||||||
|
const node = canvas.value.nodes!.find(e => e.id === id)!;
|
||||||
|
snapFinder.update(node);
|
||||||
|
|
||||||
|
actions.push({ element: { type: 'node', id }, from: { ...node, x: node.x - deltax, y: node.y - deltay }, to: { ...node } });
|
||||||
|
}
|
||||||
|
|
||||||
|
addAction('property', actions);
|
||||||
|
}
|
||||||
|
function resizeNode(ids: string[], deltax: number, deltay: number, deltaw: number, deltah: number)
|
||||||
|
{
|
||||||
|
if(ids.length === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const actions: HistoryAction<'property'>[] = [];
|
||||||
|
for(const id of ids)
|
||||||
|
{
|
||||||
|
const node = canvas.value.nodes!.find(e => e.id === id)!;
|
||||||
|
snapFinder.update(node);
|
||||||
|
|
||||||
|
actions.push({ element: { type: 'node', id }, from: { ...node, x: node.x - deltax, y: node.y - deltay, width: node.width - deltaw, height: node.height - deltah }, to: { ...node } });
|
||||||
|
}
|
||||||
|
|
||||||
|
addAction('property', actions);
|
||||||
|
}
|
||||||
|
function select(element: Element)
|
||||||
|
{
|
||||||
|
if(focusing.value && (focusing.value.id !== element.id || focusing.value.type !== element.type))
|
||||||
|
{
|
||||||
|
unselect();
|
||||||
|
}
|
||||||
|
|
||||||
|
focusing.value = element;
|
||||||
|
|
||||||
|
focused.value?.dom?.addEventListener('click', stopPropagation, { passive: true });
|
||||||
|
canvasRef.value?.addEventListener('click', unselect, { once: true });
|
||||||
|
updateToolbarTransform();
|
||||||
|
}
|
||||||
|
function edit(element: Element)
|
||||||
|
{
|
||||||
|
editing.value = element;
|
||||||
|
|
||||||
|
focused.value?.dom?.addEventListener('wheel', stopPropagation, { passive: true });
|
||||||
|
focused.value?.dom?.addEventListener('dblclick', stopPropagation, { passive: true });
|
||||||
|
canvasRef.value?.addEventListener('click', unselect, { once: true });
|
||||||
|
}
|
||||||
|
function createNode(e: MouseEvent)
|
||||||
|
{
|
||||||
|
let box = canvasRef.value?.getBoundingClientRect()!;
|
||||||
|
const width = 250, height = 100;
|
||||||
|
const x = (e.layerX / zoom.value) - dispX.value - (width / 2);
|
||||||
|
const y = (e.layerY / zoom.value) - dispY.value - (height / 2);
|
||||||
|
const node: CanvasNode = { id: getID(16), x, y, width, height, type: 'text' };
|
||||||
|
|
||||||
|
if(!canvas.value.nodes)
|
||||||
|
canvas.value.nodes = [node];
|
||||||
|
else
|
||||||
|
canvas.value.nodes.push(node);
|
||||||
|
|
||||||
|
snapFinder.add(node);
|
||||||
|
addAction('create', [{ element: { type: 'node', id: node.id }, from: undefined, to: node }]);
|
||||||
|
}
|
||||||
|
function remove(elements: Element[])
|
||||||
|
{
|
||||||
|
if(elements.length === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const actions: HistoryAction<'remove'>[] = [];
|
||||||
|
focusing.value = undefined;
|
||||||
|
editing.value = undefined;
|
||||||
|
|
||||||
|
const c = canvas.value;
|
||||||
|
|
||||||
|
for(const element of elements)
|
||||||
|
{
|
||||||
|
if(element.type === 'node')
|
||||||
|
{
|
||||||
|
const edges = c.edges?.map((e, i) => ({ id: e.id, from: e.fromNode, to: e.toNode, index: i }))?.filter(e => e.from === element.id || e.to === element.id) ?? [];
|
||||||
|
for(let i = edges.length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
actions.push({ element: { type: 'edge', id: edges[i].id }, from: c.edges!.splice(edges[i].index, 1)[0], to: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = c.nodes!.findIndex(e => e.id === element.id);
|
||||||
|
const node = c.nodes!.splice(index, 1)[0];
|
||||||
|
|
||||||
|
snapFinder.remove(node);
|
||||||
|
actions.push({ element: { type: 'node', id: element.id }, from: node, to: undefined });
|
||||||
|
}
|
||||||
|
else if(element.type === 'edge' && !actions.find(e => e.element.type === 'edge' && e.element.id === element.id))
|
||||||
|
{
|
||||||
|
const index = c.edges!.findIndex(e => e.id === element.id);
|
||||||
|
actions.push({ element: { type: 'edge', id: element.id }, from: c.edges!.splice(index, 1)[0], to: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.value = c;
|
||||||
|
|
||||||
|
addAction('remove', actions);
|
||||||
|
}
|
||||||
|
function dragEdgeTo(e: MouseEvent): void
|
||||||
|
{
|
||||||
|
(fakeEdge.value.to as Position).x += e.movementX / zoom.value;
|
||||||
|
(fakeEdge.value.to as Position).y += e.movementY / zoom.value;
|
||||||
|
|
||||||
|
const result = snapFinder.findEdgeSnapPosition(fakeEdge.value.drag!.id, fakeEdge.value.to!.x, fakeEdge.value.to!.y);
|
||||||
|
|
||||||
|
fakeEdge.value.snapped = result ? { node: result.node, side: result.direction } : undefined;
|
||||||
|
fakeEdge.value.path = bezier((fakeEdge.value.from as Position), fakeEdge.value.fromSide!, result ?? (fakeEdge.value.to as Position), result?.direction ?? fakeEdge.value.toSide!);
|
||||||
|
}
|
||||||
|
function dragEndEdgeTo(e: MouseEvent): void
|
||||||
|
{
|
||||||
|
window.removeEventListener('mousemove', dragEdgeTo);
|
||||||
|
window.removeEventListener('mouseup', dragEndEdgeTo);
|
||||||
|
|
||||||
|
if(fakeEdge.value.snapped)
|
||||||
|
{
|
||||||
|
const node = canvas.value.nodes!.find(e => e.id === fakeEdge.value.drag!.id)!;
|
||||||
|
const edge: CanvasEdge = { fromNode: fakeEdge.value.drag!.id, fromSide: fakeEdge.value.fromSide!, toNode: fakeEdge.value.snapped.node, toSide: fakeEdge.value.snapped.side, id: getID(16), color: node.color };
|
||||||
|
canvas.value.edges?.push(edge);
|
||||||
|
|
||||||
|
addAction('create', [{ from: undefined, to: edge, element: { id: edge.id, type: 'edge' } }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeEdge.value = {};
|
||||||
|
}
|
||||||
|
function dragStartEdgeTo(id: string, e: MouseEvent, direction: Direction): void
|
||||||
|
{
|
||||||
|
const node = canvas.value.nodes!.find(e => e.id === id)!;
|
||||||
|
fakeEdgeFromNode(node, direction);
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', dragEdgeTo, { passive: true });
|
||||||
|
window.addEventListener('mouseup', dragEndEdgeTo, { passive: true });
|
||||||
|
}
|
||||||
|
function dragEdgeSide(e: MouseEvent): void
|
||||||
|
{
|
||||||
|
if(fakeEdge.value.drag?.type === 'node')
|
||||||
|
return;
|
||||||
|
|
||||||
|
const destination = fakeEdge.value.drag!.destination;
|
||||||
|
const pos = fakeEdge.value[destination]!;
|
||||||
|
|
||||||
|
pos.x += e.movementX / zoom.value;
|
||||||
|
pos.y += e.movementY / zoom.value;
|
||||||
|
|
||||||
|
const result = snapFinder.findEdgeSnapPosition(fakeEdge.value.drag!.node, pos.x, pos.y);
|
||||||
|
|
||||||
|
fakeEdge.value.snapped = result ? { node: result.node, side: result.direction } : undefined;
|
||||||
|
fakeEdge.value.path = bezier(destination === 'from' ? (result ?? pos) : fakeEdge.value.from!, destination === 'from' ? result?.direction ?? fakeEdge.value.fromSide! : fakeEdge.value.fromSide!, destination === 'to' ? (result ?? pos) : fakeEdge.value.to!, destination === 'to' ? result?.direction ?? fakeEdge.value.toSide! : fakeEdge.value.toSide!);
|
||||||
|
}
|
||||||
|
function dragEndEdgeSide(e: MouseEvent): void
|
||||||
|
{
|
||||||
|
if(fakeEdge.value.drag?.type === 'node')
|
||||||
|
return;
|
||||||
|
|
||||||
|
window.removeEventListener('mousemove', dragEdgeSide);
|
||||||
|
window.removeEventListener('mouseup', dragEndEdgeSide);
|
||||||
|
|
||||||
|
if(fakeEdge.value.snapped)
|
||||||
|
{
|
||||||
|
const edge = canvas.value.edges!.find(e => e.id === fakeEdge.value.drag?.id)!
|
||||||
|
const old = { ... edge };
|
||||||
|
|
||||||
|
const destination = fakeEdge.value.drag!.destination;
|
||||||
|
|
||||||
|
edge.fromNode = destination === 'to' ? fakeEdge.value.drag!.node : fakeEdge.value.snapped.node;
|
||||||
|
edge.fromSide = destination === 'to' ? fakeEdge.value.fromSide! : fakeEdge.value.snapped.side;
|
||||||
|
|
||||||
|
edge.toNode = destination === 'from' ? fakeEdge.value.drag!.node : fakeEdge.value.snapped.node;
|
||||||
|
edge.toSide = destination === 'from' ? fakeEdge.value.toSide! : fakeEdge.value.snapped.side;
|
||||||
|
|
||||||
|
addAction('property', [{ from: old, to: edge, element: { id: edge.id, type: 'edge' } }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeEdge.value = {};
|
||||||
|
}
|
||||||
|
function dragStartEdgeSide(id: string, e: MouseEvent, direction: 'from' | 'to'): void
|
||||||
|
{
|
||||||
|
const edge = canvas.value.edges!.find(e => e.id === id)!;
|
||||||
|
fakeEdgeFromEdge(edge, direction);
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', dragEdgeSide, { passive: true });
|
||||||
|
window.addEventListener('mouseup', dragEndEdgeSide, { passive: true });
|
||||||
|
}
|
||||||
|
function fakeEdgeFromEdge(edge: CanvasEdge, direction: 'from' | 'to'): void
|
||||||
|
{
|
||||||
|
fakeEdge.value.drag = { type: 'edge', id: edge.id, destination: direction, node: direction === 'to' ? edge.fromNode : edge.toNode };
|
||||||
|
|
||||||
|
const destinationNode = direction === 'from' ? canvas.value.nodes!.find(e => e.id === edge.fromNode)! : canvas.value.nodes!.find(e => e.id === edge.toNode)!;
|
||||||
|
const otherNode = direction === 'from' ? canvas.value.nodes!.find(e => e.id === edge.toNode)! : canvas.value.nodes!.find(e => e.id === edge.fromNode)!;
|
||||||
|
const destinationPos = posFromDir(getBbox(destinationNode), direction === 'from' ? edge.fromSide : edge.toSide);
|
||||||
|
const otherPos = posFromDir(getBbox(otherNode), direction === 'from' ? edge.toSide : edge.fromSide);
|
||||||
|
|
||||||
|
fakeEdge.value.from = direction === 'from' ? destinationPos : otherPos;
|
||||||
|
fakeEdge.value.fromSide = edge.fromSide;
|
||||||
|
|
||||||
|
fakeEdge.value.to = direction === 'to' ? destinationPos : otherPos;
|
||||||
|
fakeEdge.value.toSide = edge.toSide;
|
||||||
|
|
||||||
|
fakeEdge.value.path = bezier(destinationPos, edge.fromSide, otherPos, edge.toSide);
|
||||||
|
fakeEdge.value.hex = edge.color?.hex;
|
||||||
|
|
||||||
|
fakeEdge.value.style = edge?.color ? edge.color?.class ?
|
||||||
|
{ fill: `fill-light-${edge.color?.class} dark:fill-dark-${edge.color?.class}`, stroke: `stroke-light-${edge.color?.class} dark:stroke-dark-${edge.color?.class}` } :
|
||||||
|
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]` } :
|
||||||
|
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40` };
|
||||||
|
}
|
||||||
|
function fakeEdgeFromNode(node: CanvasNode, direction: Direction): void
|
||||||
|
{
|
||||||
|
const pos = posFromDir(getBbox(node), direction);
|
||||||
|
|
||||||
|
fakeEdge.value.drag = { type: 'node', id: node.id };
|
||||||
|
|
||||||
|
fakeEdge.value.from = { ... pos };
|
||||||
|
fakeEdge.value.fromSide = direction;
|
||||||
|
|
||||||
|
fakeEdge.value.to = { ... pos };
|
||||||
|
fakeEdge.value.toSide = opposite[direction];
|
||||||
|
|
||||||
|
fakeEdge.value.path = bezier(pos, fakeEdge.value.fromSide!, pos, fakeEdge.value.toSide!);
|
||||||
|
fakeEdge.value.hex = node.color?.hex;
|
||||||
|
|
||||||
|
fakeEdge.value.style = node?.color ? node.color?.class ?
|
||||||
|
{ fill: `fill-light-${node.color?.class} dark:fill-dark-${node.color?.class}`, stroke: `stroke-light-${node.color?.class} dark:stroke-dark-${node.color?.class}` } :
|
||||||
|
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]` } :
|
||||||
|
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40` };
|
||||||
|
}
|
||||||
|
function editNodeProperty<T extends keyof CanvasNode>(ids: string[], property: T, value: CanvasNode[T])
|
||||||
|
{
|
||||||
|
if(ids.length === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const actions: HistoryAction<'property'>[] = [];
|
||||||
|
|
||||||
|
for(const id of ids)
|
||||||
|
{
|
||||||
|
const copy = JSON.parse(JSON.stringify(canvas.value.nodes!.find(e => e.id === id)!)) as CanvasNode;
|
||||||
|
canvas.value.nodes!.find(e => e.id === id)![property] = value;
|
||||||
|
actions.push({ element: { type: 'node', id }, from: copy, to: canvas.value.nodes!.find(e => e.id === id)! });
|
||||||
|
}
|
||||||
|
|
||||||
|
addAction('property', actions);
|
||||||
|
}
|
||||||
|
function editEdgeProperty<T extends keyof CanvasEdge>(ids: string[], property: T, value: CanvasEdge[T])
|
||||||
|
{
|
||||||
|
if(ids.length === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const actions: HistoryAction<'property'>[] = [];
|
||||||
|
|
||||||
|
for(const id of ids)
|
||||||
|
{
|
||||||
|
const copy = JSON.parse(JSON.stringify(canvas.value.edges!.find(e => e.id === id)!)) as CanvasEdge;
|
||||||
|
canvas.value.edges!.find(e => e.id === id)![property] = value;
|
||||||
|
actions.push({ element: { type: 'edge', id }, from: copy, to: canvas.value.edges!.find(e => e.id === id)! });
|
||||||
|
}
|
||||||
|
|
||||||
|
addAction('property', actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unselect = () => {
|
||||||
|
if(focusing.value !== undefined)
|
||||||
|
{
|
||||||
|
focused.value?.dom?.removeEventListener('click', stopPropagation);
|
||||||
|
focused.value?.unselect();
|
||||||
|
updateToolbarTransform();
|
||||||
|
}
|
||||||
|
focusing.value = undefined;
|
||||||
|
|
||||||
|
if(editing.value !== undefined)
|
||||||
|
{
|
||||||
|
edited.value?.dom?.removeEventListener('wheel', stopPropagation);
|
||||||
|
edited.value?.dom?.removeEventListener('dblclick', stopPropagation);
|
||||||
|
edited.value?.dom?.removeEventListener('click', stopPropagation);
|
||||||
|
edited.value?.unselect();
|
||||||
|
}
|
||||||
|
editing.value = undefined;
|
||||||
|
};
|
||||||
|
const undo = () => {
|
||||||
|
if(!historyCursor.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for(const action of historyCursor.value.actions)
|
||||||
|
{
|
||||||
|
if(action.element.type === 'node')
|
||||||
|
{
|
||||||
|
switch(historyCursor.value.event)
|
||||||
|
{
|
||||||
|
case 'create':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'create'>;
|
||||||
|
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
|
||||||
|
snapFinder.remove(canvas.value.nodes!.splice(index, 1)[0]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'remove':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'remove'>;
|
||||||
|
canvas.value.nodes!.push(a.from as CanvasNode);
|
||||||
|
snapFinder.add(a.from as CanvasNode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'property':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'property'>;
|
||||||
|
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
|
||||||
|
canvas.value.nodes![index] = a.from as CanvasNode;
|
||||||
|
snapFinder.update(a.from as CanvasNode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(action.element.type === 'edge')
|
||||||
|
{
|
||||||
|
switch(historyCursor.value.event)
|
||||||
|
{
|
||||||
|
case 'create':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'create'>;
|
||||||
|
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
|
||||||
|
canvas.value.edges!.splice(index, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'remove':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'remove'>;
|
||||||
|
canvas.value.edges!.push(a.from! as CanvasEdge);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'property':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'property'>;
|
||||||
|
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
|
||||||
|
canvas.value.edges![index] = a.from as CanvasEdge;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
historyPos.value--;
|
||||||
|
};
|
||||||
|
const redo = () => {
|
||||||
|
if(!history.value || history.value.length - 1 <= historyPos.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
historyPos.value++;
|
||||||
|
|
||||||
|
if(!historyCursor.value)
|
||||||
|
{
|
||||||
|
historyPos.value--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const action of historyCursor.value.actions)
|
||||||
|
{
|
||||||
|
if(action.element.type === 'node')
|
||||||
|
{
|
||||||
|
switch(historyCursor.value.event)
|
||||||
|
{
|
||||||
|
case 'create':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'create'>;
|
||||||
|
canvas.value.nodes!.push(a.to as CanvasNode);
|
||||||
|
snapFinder.add(a.to as CanvasNode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'remove':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'remove'>;
|
||||||
|
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
|
||||||
|
snapFinder.remove(canvas.value.nodes!.splice(index, 1)[0]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'property':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'property'>;
|
||||||
|
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
|
||||||
|
canvas.value.nodes![index] = a.to as CanvasNode;
|
||||||
|
snapFinder.update(a.to as CanvasNode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(action.element.type === 'edge')
|
||||||
|
{
|
||||||
|
switch(historyCursor.value.event)
|
||||||
|
{
|
||||||
|
case 'create':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'create'>;
|
||||||
|
canvas.value.edges!.push(a.to as CanvasEdge);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'remove':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'remove'>;
|
||||||
|
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
|
||||||
|
canvas.value.edges!.splice(index, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'property':
|
||||||
|
{
|
||||||
|
const a = action as HistoryAction<'property'>;
|
||||||
|
const index = canvas.value.edges!.findIndex(e => e.id === action.element.id);
|
||||||
|
canvas.value.edges![index] = a.to as CanvasEdge;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useShortcuts({
|
||||||
|
meta_z: undo,
|
||||||
|
meta_y: redo,
|
||||||
|
Delete: () => { if(focusing.value !== undefined) { remove([focusing.value]) } }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="canvasRef" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none" @dblclick.left="createNode">
|
||||||
|
<div class="flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4" @click="stopPropagation" @dblclick="stopPropagation">
|
||||||
|
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
|
||||||
|
<Tooltip message="Zoom avant" side="right">
|
||||||
|
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:plus" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip message="Reset" side="right">
|
||||||
|
<div @click="zoom = 1; updateTransform();" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:reload" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip message="Tout contenir" side="right">
|
||||||
|
<div @click="reset" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:corners" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip message="Zoom arrière" side="right">
|
||||||
|
<div @click="zoom = clamp(zoom / 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:minus" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
|
||||||
|
<Tooltip message="Annuler (Ctrl+Z)" side="right">
|
||||||
|
<div @click="undo" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer" :class="{ 'text-light-50 dark:text-dark-50 !cursor-default hover:bg-transparent dark:hover:bg-transparent': historyPos === -1 }">
|
||||||
|
<Icon icon="ph:arrow-bend-up-left" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip message="Retablir (Ctrl+Y)" side="right">
|
||||||
|
<div @click="redo" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer" :class="{ 'text-light-50 dark:text-dark-50 !cursor-default hover:bg-transparent dark:hover:bg-transparent': historyPos === history.length - 1 }">
|
||||||
|
<Icon icon="ph:arrow-bend-up-right" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
|
||||||
|
<Tooltip message="Préférences" side="right">
|
||||||
|
<Dialog title="Préférences" iconClose>
|
||||||
|
<template #trigger>
|
||||||
|
<div class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:gear" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<Switch v-model="canvasSettings.neighborSnap" label="S'accrocher aux voisins" @update:model-value="snapFinder.config.preferences = canvasSettings" />
|
||||||
|
<Switch v-model="canvasSettings.gridSnap" label="S'accrocher à la grille" @update:model-value="(v) => { canvasSettings.spacing = v ? 32 : undefined; snapFinder.config.preferences = canvasSettings }" />
|
||||||
|
<NumberPicker v-model="canvasSettings.spacing" label="Taille de la grille" :disabled="!canvasSettings.gridSnap" @update:model-value="(v) => { spacing = v; updateTransform(); snapFinder.config.preferences = canvasSettings}" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip message="Aide" side="right">
|
||||||
|
<Dialog title="Aide" iconClose>
|
||||||
|
<template #trigger>
|
||||||
|
<div class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:question-mark-circled" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<div class="flex flex-row justify-between px-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ProseH4>Ordinateur</ProseH4>
|
||||||
|
<div class="flex items-center"><Icon icon="ph:mouse-left-click-fill" class="w-6 h-6"/>: Selectionner</div>
|
||||||
|
<div class="flex items-center"><Icon icon="ph:mouse-left-click-fill" class="w-6 h-6"/><Icon icon="ph:mouse-left-click-fill" class="w-6 h-6"/>: Modifier</div>
|
||||||
|
<div class="flex items-center"><Icon icon="ph:mouse-middle-click-fill" class="w-6 h-6"/>: Déplacer</div>
|
||||||
|
<div class="flex items-center"><Icon icon="ph:mouse-right-click-fill" class="w-6 h-6"/>: Menu</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ProseH4>Mobile</ProseH4>
|
||||||
|
<div class="flex items-center"><Icon icon="ph:hand-tap" class="w-6 h-6"/>: Selectionner</div>
|
||||||
|
<div class="flex items-center"><Icon icon="ph:hand-tap" class="w-6 h-6"/><Icon icon="ph:hand-tap" class="w-6 h-6"/>: Modifier</div>
|
||||||
|
<div class="flex items-center"><Icon icon="mdi:gesture-pinch" class="w-6 h-6"/>: Zoomer</div>
|
||||||
|
<div class="flex items-center"><Icon icon="ph:hand-tap" class="w-6 h-6"/> maintenu: Menu</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg class="absolute top-0 left-0 w-full h-full pointer-events-none">
|
||||||
|
<pattern ref="patternRef" id="canvasPattern" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="0.75" cy="0.75" r="0.75" class="fill-light-35 dark:fill-dark-35"></circle>
|
||||||
|
</pattern>
|
||||||
|
<rect x="0" y="0" width="100%" height="100%" fill="url(#canvasPattern)"></rect>
|
||||||
|
</svg>
|
||||||
|
<div ref="transformRef" :style="{
|
||||||
|
'transform-origin': 'center center',
|
||||||
|
}" class="h-full">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none">
|
||||||
|
<div class="absolute z-20 destination-bottom" ref="toolbarRef">
|
||||||
|
<template v-if="focusing">
|
||||||
|
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row" v-if="focusing.type === 'node'">
|
||||||
|
<PopoverRoot>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div @click="stopPropagation">
|
||||||
|
<Tooltip message="Couleur" side="top">
|
||||||
|
<div class="w-10 h-10 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="ph:palette" class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverPortal disabled>
|
||||||
|
<PopoverContent align="center" side="top" class="bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 m-2">
|
||||||
|
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row *:cursor-pointer">
|
||||||
|
<div @click="editNodeProperty([focusing.id], 'color', undefined)" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-40 dark:bg-dark-40 w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editNodeProperty([focusing.id], 'color', { class: 'red' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-red dark:bg-dark-red w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editNodeProperty([focusing.id], 'color', { class: 'orange' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-orange dark:bg-dark-orange w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editNodeProperty([focusing.id], 'color', { class: 'yellow' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-yellow dark:bg-dark-yellow w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editNodeProperty([focusing.id], 'color', { class: 'green' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-green dark:bg-dark-green w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editNodeProperty([focusing.id], 'color', { class: 'cyan' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-cyan dark:bg-dark-cyan w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editNodeProperty([focusing.id], 'color', { class: 'purple' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-purple dark:bg-dark-purple w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<div @click="stopPropagation" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span style="background: conic-gradient(red, yellow, green, blue, purple, red)" class="w-4 h-4 block relative"></span><input @change="(e: Event) => editNodeProperty([focusing!.id], 'color', { hex: (e.target as HTMLInputElement).value })" type="color" class="appearance-none w-0 h-0 absolute" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</PopoverPortal>
|
||||||
|
</PopoverRoot>
|
||||||
|
<Tooltip message="Supprimer" side="top">
|
||||||
|
<div @click="remove([focusing])" class="w-10 h-10 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:trash" class="text-light-red dark:text-dark-red w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row" v-else>
|
||||||
|
<PopoverRoot>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div @click="stopPropagation">
|
||||||
|
<Tooltip message="Couleur" side="top">
|
||||||
|
<div class="w-10 h-10 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="ph:palette" class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverPortal disabled>
|
||||||
|
<PopoverContent align="center" side="top" class="bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 m-2">
|
||||||
|
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row *:cursor-pointer">
|
||||||
|
<div @click="editEdgeProperty([focusing.id], 'color', undefined)" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-40 dark:bg-dark-40 w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'red' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-red dark:bg-dark-red w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'orange' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-orange dark:bg-dark-orange w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'yellow' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-yellow dark:bg-dark-yellow w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'green' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-green dark:bg-dark-green w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'cyan' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-cyan dark:bg-dark-cyan w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<div @click="editEdgeProperty([focusing.id], 'color', { class: 'purple' })" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span class="bg-light-purple dark:bg-dark-purple w-4 h-4 block"></span>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<div @click="stopPropagation" class="p-2 hover:bg-light-35 hover:dark:bg-dark-35">
|
||||||
|
<span style="background: conic-gradient(red, yellow, green, blue, purple, red)" class="w-4 h-4 block relative"></span><input @change="(e: Event) => editEdgeProperty([focusing!.id], 'color', { hex: (e.target as HTMLInputElement).value })" type="color" class="appearance-none w-0 h-0 absolute" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</PopoverPortal>
|
||||||
|
</PopoverRoot>
|
||||||
|
<Tooltip message="Supprimer" side="top">
|
||||||
|
<div @click="remove([focusing])" class="w-10 h-10 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:trash" class="text-light-red dark:text-dark-red w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CanvasNodeEditor v-for="node of canvas.nodes" :key="node.id" ref="nodes" :node="node" :zoom="zoom"
|
||||||
|
@select="select" @edit="edit" @move="(i, x, y) => moveNode([i], x, y)" @resize="(i, x, y, w, h) => resizeNode([i], x, y, w, h)" @input="(id, text) => editNodeProperty([id], node.type === 'group' ? 'label' : 'text', text)" :snap="snapFinder.findNodeSnapPosition.bind(snapFinder)" @edge="dragStartEdgeTo" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CanvasEdgeEditor v-for="edge of canvas.edges" :key="edge.id" ref="edges" :edge="edge" :nodes="canvas.nodes!" @select="select" @edit="edit" @input="(id, text) => editEdgeProperty([id], 'label', text)" @drag="dragStartEdgeSide" />
|
||||||
|
<div v-if="fakeEdge.path" class="absolute overflow-visible">
|
||||||
|
<svg class="absolute top-0 overflow-visible h-px w-px">
|
||||||
|
<g :style="{'--canvas-color': fakeEdge.hex}" class="z-0">
|
||||||
|
<g :style="`transform: translate(${fakeEdge.path!.to.x}px, ${fakeEdge.path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[fakeEdge.path!.side]}deg);`">
|
||||||
|
<polygon :class="fakeEdge.style?.fill" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
|
||||||
|
</g>
|
||||||
|
<path :style="`stroke-width: calc(3px * var(--zoom-multiplier));`" style="stroke-linecap: butt;" :class="fakeEdge.style?.stroke" class="fill-none stroke-[4px]" :d="fakeEdge.path.path"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg class="absolute overflow-visible top-0 h-px w-px fill-accent-purple stroke-accent-purple stroke-1 z-50">
|
||||||
|
<g v-for="hint of hints">
|
||||||
|
<circle :cx="hint.start.x" :cy="hint.start.y" r="3" />
|
||||||
|
<circle v-if="hint.end" :cx="hint.end.x" :cy="hint.end.y" r="3" />
|
||||||
|
<line v-if="hint.end" :x1="hint.start.x" :x2="hint.end.x" :y1="hint.start.y" :y2="hint.end.y"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,29 +1,154 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts">
|
||||||
import { dropCursor, crosshairCursor, keymap, EditorView } from '@codemirror/view';
|
import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, WidgetType, type DecorationSet } from '@codemirror/view';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { Annotation, EditorState, RangeValue, SelectionRange, type Range } from '@codemirror/state';
|
||||||
import { indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldKeymap } from '@codemirror/language';
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||||
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands';
|
import { bracketMatching, defaultHighlightStyle, foldKeymap, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
|
||||||
import { searchKeymap } from '@codemirror/search';
|
import { search, searchKeymap } from '@codemirror/search';
|
||||||
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
||||||
import { lintKeymap } from '@codemirror/lint';
|
import { lintKeymap } from '@codemirror/lint';
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
|
import { IterMode, Tree } from '@lezer/common';
|
||||||
|
import { tags } from '@lezer/highlight';
|
||||||
|
const External = Annotation.define<boolean>();
|
||||||
|
const Hidden = Decoration.mark({ class: 'hidden' });
|
||||||
|
const Bullet = Decoration.mark({ class: '*:hidden before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4' });
|
||||||
|
const Blockquote = Decoration.line({ class: '*:hidden before:block !ps-4 relative before:absolute before:top-0 before:bottom-0 before:left-0 before:w-1 before:bg-none before:bg-light-30 dark:before:bg-dark-30' });
|
||||||
|
|
||||||
|
const TagTag = tags.special(tags.content);
|
||||||
|
|
||||||
|
const intersects = (a: {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}, b: {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}) => !(a.to < b.from || b.to < a.from);
|
||||||
|
|
||||||
|
const highlight = HighlightStyle.define([
|
||||||
|
{ tag: tags.heading1, class: 'text-5xl pt-4 pb-2 after:hidden' },
|
||||||
|
{ tag: tags.heading2, class: 'text-4xl pt-4 pb-2 ps-1 leading-loose after:hidden' },
|
||||||
|
{ tag: tags.heading3, class: 'text-2xl font-bold pt-1 after:hidden' },
|
||||||
|
{ tag: tags.heading4, class: 'text-xl font-semibold pt-1 after:hidden variant-cap' },
|
||||||
|
{ tag: tags.meta, color: "#404740" },
|
||||||
|
{ tag: tags.link, textDecoration: "underline" },
|
||||||
|
{ tag: tags.heading, textDecoration: "underline", fontWeight: "bold" },
|
||||||
|
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||||
|
{ tag: tags.strong, fontWeight: "bold" },
|
||||||
|
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
||||||
|
{ tag: tags.keyword, color: "#708" },
|
||||||
|
{ tag: TagTag, class: 'cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
class Decorator
|
||||||
|
{
|
||||||
|
static hiddenNodes: string[] = [
|
||||||
|
'HardBreak',
|
||||||
|
'LinkMark',
|
||||||
|
'EmphasisMark',
|
||||||
|
'CodeMark',
|
||||||
|
'CodeInfo',
|
||||||
|
'URL',
|
||||||
|
]
|
||||||
|
decorations: DecorationSet;
|
||||||
|
constructor(view: EditorView)
|
||||||
|
{
|
||||||
|
this.decorations = Decoration.set(this.iterate(syntaxTree(view.state), view.visibleRanges, []), true);
|
||||||
|
}
|
||||||
|
update(update: ViewUpdate)
|
||||||
|
{
|
||||||
|
if(!update.docChanged && !update.viewportChanged && !update.selectionSet)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.decorations = this.decorations.update({
|
||||||
|
filter: (f, t, v) => false,
|
||||||
|
add: this.iterate(syntaxTree(update.state), update.view.visibleRanges, update.state.selection.ranges),
|
||||||
|
sort: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
iterate(tree: Tree, visible: readonly {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}[], selection: readonly SelectionRange[]): Range<Decoration>[]
|
||||||
|
{
|
||||||
|
const decorations: Range<Decoration>[] = [];
|
||||||
|
|
||||||
|
for (let { from, to } of visible) {
|
||||||
|
tree.iterate({
|
||||||
|
from, to, mode: IterMode.IgnoreMounts,
|
||||||
|
enter: node => {
|
||||||
|
if(node.node.parent && selection.some(e => intersects(e, node.node.parent!)))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
else if(node.name === 'HeaderMark')
|
||||||
|
decorations.push(Hidden.range(node.from, node.to + 1));
|
||||||
|
|
||||||
|
else if(Decorator.hiddenNodes.includes(node.name))
|
||||||
|
decorations.push(Hidden.range(node.from, node.to));
|
||||||
|
|
||||||
|
else if(node.matchContext(['BulletList', 'ListItem']) && node.name === 'ListMark')
|
||||||
|
decorations.push(Bullet.range(node.from, node.to + 1));
|
||||||
|
|
||||||
|
else if(node.matchContext(['Blockquote']))
|
||||||
|
decorations.push(Blockquote.range(node.from, node.from));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return decorations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { autofocus = false } = defineProps<{
|
||||||
|
placeholder?: string
|
||||||
|
autofocus?: boolean
|
||||||
|
}>();
|
||||||
|
const model = defineModel<string>();
|
||||||
|
|
||||||
const editor = useTemplateRef('editor');
|
const editor = useTemplateRef('editor');
|
||||||
const view = ref<EditorView>();
|
const view = ref<EditorView>();
|
||||||
const state = ref<EditorState>();
|
|
||||||
|
|
||||||
const model = defineModel<string>();
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if(editor.value)
|
if(editor.value)
|
||||||
{
|
{
|
||||||
state.value = EditorState.create({
|
view.value = new EditorView({
|
||||||
doc: model.value,
|
doc: model.value,
|
||||||
|
parent: editor.value,
|
||||||
extensions: [
|
extensions: [
|
||||||
|
markdown({
|
||||||
|
base: markdownLanguage,
|
||||||
|
extensions: {
|
||||||
|
defineNodes: [
|
||||||
|
{ name: "Tag", style: TagTag },
|
||||||
|
{ name: "TagMark", style: tags.processingInstruction }
|
||||||
|
],
|
||||||
|
parseInline: [{
|
||||||
|
name: "Tag",
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
if (next != 35 || cx.char(pos + 1) == 35) return -1;
|
||||||
|
let elts = [cx.elt("TagMark", pos, pos + 1)];
|
||||||
|
for (let i = pos + 1; i < cx.end; i++) {
|
||||||
|
let next = cx.char(i);
|
||||||
|
if (next == 35)
|
||||||
|
return cx.addElement(cx.elt("Tag", pos, i + 1, elts.concat(cx.elt("TagMark", i, i + 1))));
|
||||||
|
if (next == 92)
|
||||||
|
elts.push(cx.elt("Escape", i, i++ + 2));
|
||||||
|
if (next == 32 || next == 9 || next == 10 || next == 13) break;
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}),
|
||||||
history(),
|
history(),
|
||||||
|
search(),
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
EditorState.allowMultipleSelections.of(true),
|
EditorState.allowMultipleSelections.of(true),
|
||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
syntaxHighlighting(highlight),
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
closeBrackets(),
|
closeBrackets(),
|
||||||
crosshairCursor(),
|
crosshairCursor(),
|
||||||
@@ -36,20 +161,32 @@ onMounted(() => {
|
|||||||
...foldKeymap,
|
...foldKeymap,
|
||||||
...completionKeymap,
|
...completionKeymap,
|
||||||
...lintKeymap
|
...lintKeymap
|
||||||
])
|
]),
|
||||||
|
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||||
|
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
|
||||||
|
{
|
||||||
|
model.value = viewUpdate.state.doc.toString();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
EditorView.contentAttributes.of({spellcheck: "true"}),
|
||||||
|
ViewPlugin.fromClass(Decorator, {
|
||||||
|
decorations: e => e.decorations,
|
||||||
|
})
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
view.value = new EditorView({
|
|
||||||
state: state.value,
|
if(autofocus)
|
||||||
parent: editor.value,
|
{
|
||||||
});
|
view.value.focus();
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (view.value) {
|
if (view.value)
|
||||||
view.value?.destroy()
|
{
|
||||||
view.value = undefined
|
view.value?.destroy();
|
||||||
|
view.value = undefined;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,26 +197,39 @@ watchEffect(() => {
|
|||||||
const currentValue = view.value ? view.value.state.doc.toString() : "";
|
const currentValue = view.value ? view.value.state.doc.toString() : "";
|
||||||
if (view.value && model.value !== currentValue) {
|
if (view.value && model.value !== currentValue) {
|
||||||
view.value.dispatch({
|
view.value.dispatch({
|
||||||
changes: { from: 0, to: currentValue.length, insert: model.value || "" }
|
changes: { from: 0, to: currentValue.length, insert: model.value || "" },
|
||||||
|
annotations: [External.of(true)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineExpose({ focus: () => editor.value?.focus() });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-1 justify-center items-start p-12">
|
<div ref="editor" class="flex flex-1 w-full justify-stretch items-stretch py-2 px-1.5 font-sans text-base"></div>
|
||||||
<div ref="editor" class="flex flex-1 justify-center items-stretch border border-light-35 dark:border-dark-35 caret-light-100 dark:caret-dark-100" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.variant-cap
|
||||||
|
{
|
||||||
|
font-variant: small-caps;
|
||||||
|
}
|
||||||
.cm-editor
|
.cm-editor
|
||||||
{
|
{
|
||||||
@apply bg-transparent;
|
@apply bg-transparent;
|
||||||
|
@apply flex-1 h-full;
|
||||||
|
@apply font-sans;
|
||||||
|
|
||||||
|
@apply text-light-100 dark:text-dark-100;
|
||||||
}
|
}
|
||||||
.cm-editor .cm-content
|
.cm-editor .cm-content
|
||||||
{
|
{
|
||||||
@apply caret-light-100;
|
@apply caret-light-100 dark:caret-dark-100;
|
||||||
@apply dark:caret-dark-100;
|
}
|
||||||
|
.cm-line
|
||||||
|
{
|
||||||
|
@apply text-base;
|
||||||
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
40
components/FramedEditor.vue
Normal file
40
components/FramedEditor.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<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>
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<template v-if="content && content.length > 0">
|
|
||||||
<Suspense :timeout="0">
|
|
||||||
<MarkdownRenderer #default :key="key" v-if="node" :node="node" :proses="proses"></MarkdownRenderer>
|
|
||||||
<template #fallback><Loading /></template>
|
|
||||||
</Suspense>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { hash } from 'ohash'
|
|
||||||
|
|
||||||
const { content } = defineProps({
|
|
||||||
content: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
proses: {
|
|
||||||
type: Object
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const parser = useMarkdown();
|
|
||||||
const key = computed(() => hash(content));
|
|
||||||
const node = computed(() => content ? parser(content) : undefined);
|
|
||||||
</script>
|
|
||||||
@@ -1,111 +1,49 @@
|
|||||||
<script lang="ts">
|
<template>
|
||||||
import type { RootContent, Root } from 'hast';
|
<div v-if="content && content.length > 0">
|
||||||
import { Text, Comment } from 'vue';
|
<ProsesRenderer #default v-if="data" :node="data" :proses="proses" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
import ProseP from '~/components/prose/ProseP.vue';
|
<script setup lang="ts">
|
||||||
import ProseA from '~/components/prose/ProseA.vue';
|
import type { Component } from 'vue';
|
||||||
import ProseBlockquote from '~/components/prose/ProseBlockquote.vue';
|
import { heading } from 'hast-util-heading';
|
||||||
import ProseCode from '~/components/prose/ProseCode.vue';
|
import { headingRank } from 'hast-util-heading-rank';
|
||||||
import ProsePre from '~/components/prose/ProsePre.vue';
|
import { parseId } from '~/shared/general.util';
|
||||||
import ProseEm from '~/components/prose/ProseEm.vue';
|
import type { Root } from 'hast';
|
||||||
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 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 = {
|
const { content, proses, filter } = defineProps<{
|
||||||
"p": ProseP,
|
content: string
|
||||||
"a": ProseA,
|
proses?: Record<string, string | Component>
|
||||||
"blockquote": ProseBlockquote,
|
filter?: string
|
||||||
"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,
|
|
||||||
"strong": ProseStrong,
|
|
||||||
"table": ProseTable,
|
|
||||||
"tag": ProseTag,
|
|
||||||
"thead": ProseThead,
|
|
||||||
"tbody": ProseTbody,
|
|
||||||
"td": ProseTd,
|
|
||||||
"th": ProseTh,
|
|
||||||
"tr": ProseTr,
|
|
||||||
"script": ProseScript
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineComponent({
|
const parser = useMarkdown(), data = ref<Root>();
|
||||||
name: 'MarkdownRenderer',
|
const node = computed(() => content ? parser(content) : undefined);
|
||||||
props: {
|
watch([node], () => {
|
||||||
node: {
|
if(!node.value)
|
||||||
type: Object,
|
data.value = undefined;
|
||||||
required: true
|
else if(!filter)
|
||||||
},
|
|
||||||
proses: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async setup(props) {
|
|
||||||
if(props.proses)
|
|
||||||
{
|
{
|
||||||
for(const prose of Object.keys(props.proses))
|
data.value = node.value;
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
if(typeof props.proses[prose] === 'string')
|
const start = node.value?.children.findIndex(e => heading(e) && parseId(e.properties.id as string | undefined) === filter) ?? -1;
|
||||||
props.proses[prose] = await resolveComponent(props.proses[prose]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { tags: Object.assign({}, proseList, props.proses) };
|
|
||||||
},
|
|
||||||
render(ctx: any) {
|
|
||||||
const { node, tags } = ctx;
|
|
||||||
|
|
||||||
if(!node)
|
if(start === -1)
|
||||||
return null;
|
data.value = node.value;
|
||||||
|
else
|
||||||
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')
|
let end = start;
|
||||||
|
const rank = headingRank(node.value.children[start])!;
|
||||||
|
while(end < node.value.children.length)
|
||||||
{
|
{
|
||||||
return h(Text, node.value);
|
end++;
|
||||||
|
if(heading(node.value.children[end]) && headingRank(node.value.children[end])! <= rank)
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
else if(node.type === 'comment' && node.value.length > 0 && node.value !== '\n')
|
data.value = { ...node.value, children: node.value.children.slice(start, end) };
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}, { immediate: true, });
|
||||||
</script>
|
</script>
|
||||||
115
components/ProsesRenderer.vue
Normal file
115
components/ProsesRenderer.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AvatarRoot class="inline-flex h-12 w-12 select-none items-center justify-center overflow-hidden align-middle">
|
<AvatarRoot class="inline-flex select-none items-center justify-center overflow-hidden align-middle" :class="SIZES[size]">
|
||||||
<AvatarImage class="h-full w-full object-cover" :src="src" asChild @loading-status-change="(status) => loading = status === 'loading'">
|
<AvatarImage class="h-full w-full object-cover" :src="src" asChild @loading-status-change="(status) => loading = status === 'loading'">
|
||||||
<img :src="src" />
|
<img :src="src" />
|
||||||
</AvatarImage>
|
</AvatarImage>
|
||||||
@@ -13,10 +13,18 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
const { src, icon, text } = defineProps<{
|
const { src, icon, text, size = 'medium' } = defineProps<{
|
||||||
src: string
|
src: string
|
||||||
icon?: string
|
icon?: string
|
||||||
text?: string
|
text?: string
|
||||||
|
size?: keyof typeof SIZES
|
||||||
}>();
|
}>();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
</script>
|
</script>
|
||||||
|
<script lang="ts">
|
||||||
|
const SIZES = {
|
||||||
|
'small': 'h-6',
|
||||||
|
'medium': 'h-10',
|
||||||
|
'large': 'h-16',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<CollapsibleRoot v-model:open="model" :disabled="disabled">
|
<CollapsibleRoot v-model:open="model" :disabled="disabled" :defaultOpen="defaultOpen">
|
||||||
|
<slot name="alwaysVisible"></slot>
|
||||||
<div class="flex flex-row justify-center items-center">
|
<div class="flex flex-row justify-center items-center">
|
||||||
<span v-if="!!label">{{ label }}</span>
|
<span>{{ label }}<slot name="label"></slot></span>
|
||||||
<CollapsibleTrigger class="ms-4" asChild>
|
<CollapsibleTrigger class="ms-4" asChild>
|
||||||
<Button icon :disabled="disabled">
|
<Button icon :disabled="disabled">
|
||||||
<Icon v-if="model" icon="radix-icons:cross-2" class="h-4 w-4" />
|
<Icon v-if="model" icon="radix-icons:cross-2" class="h-4 w-4" />
|
||||||
@@ -9,7 +10,6 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
</div>
|
</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]">
|
<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>
|
<slot></slot>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@@ -18,9 +18,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
const { label, disabled = false } = defineProps<{
|
const { label, disabled = false, defaultOpen = false } = defineProps<{
|
||||||
label?: string
|
label?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
defaultOpen?: boolean
|
||||||
}>();
|
}>();
|
||||||
const model = defineModel<boolean>();
|
const model = defineModel<boolean>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
80
components/base/DraggableTree.vue
Normal file
80
components/base/DraggableTree.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<TreeRoot v-bind="forward" v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 overflow-auto max-h-full">
|
||||||
|
<DraggableTreeItem v-for="item in flattenItems" :key="item._id" v-bind="item" class="group flex items-center outline-none relative cursor-pointer max-w-full" @select.prevent @toggle.prevent>
|
||||||
|
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver }">
|
||||||
|
<slot :handleToggle="handleToggle"
|
||||||
|
:handleSelect="handleSelect"
|
||||||
|
:isExpanded="isExpanded"
|
||||||
|
:isSelected="isSelected"
|
||||||
|
:isDragging="isDragging"
|
||||||
|
:isDraggedOver="isDraggedOver"
|
||||||
|
:item="item"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #hint="{ instruction }">
|
||||||
|
<div v-if="instruction">
|
||||||
|
<slot name="hint" :instruction="instruction" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DraggableTreeItem>
|
||||||
|
</TreeRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||||
|
import { useForwardPropsEmits, type FlattenedItem, type TreeRootEmits, type TreeRootProps } from 'radix-vue';
|
||||||
|
import { type Instruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
|
||||||
|
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
|
||||||
|
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||||
|
|
||||||
|
const props = defineProps<TreeRootProps<T>>();
|
||||||
|
const emits = defineEmits<TreeRootEmits<T> & {
|
||||||
|
'updateTree': [instruction: Instruction, itemId: string, targetId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default: (props: {
|
||||||
|
handleToggle: () => void,
|
||||||
|
handleSelect: () => void,
|
||||||
|
isExpanded: boolean,
|
||||||
|
isSelected: boolean,
|
||||||
|
isDragging: boolean,
|
||||||
|
isDraggedOver: boolean,
|
||||||
|
item: FlattenedItem<T>,
|
||||||
|
}) => any,
|
||||||
|
hint: (props: {
|
||||||
|
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
|
||||||
|
}) => any,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const forward = useForwardPropsEmits(props, emits);
|
||||||
|
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
const dndFunction = combine(
|
||||||
|
monitorForElements({
|
||||||
|
onDrop(args) {
|
||||||
|
const { location, source } = args;
|
||||||
|
|
||||||
|
if (!location.current.dropTargets.length)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const itemId = source.data.id as string;
|
||||||
|
const target = location.current.dropTargets[0];
|
||||||
|
const targetId = target.data.id as string;
|
||||||
|
|
||||||
|
const instruction: Instruction | null = extractInstruction(
|
||||||
|
target.data,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (instruction !== null)
|
||||||
|
{
|
||||||
|
emits('updateTree', instruction, itemId, targetId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
dndFunction();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
140
components/base/DraggableTreeItem.vue
Normal file
140
components/base/DraggableTreeItem.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<TreeItem ref="el" v-bind="forward" v-slot="{ isExpanded, isSelected, isIndeterminate, handleToggle, handleSelect }">
|
||||||
|
<slot
|
||||||
|
:is-expanded="isExpanded"
|
||||||
|
:is-selected="isSelected"
|
||||||
|
:is-indeterminate="isIndeterminate"
|
||||||
|
:handle-select="handleSelect"
|
||||||
|
:handle-toggle="handleToggle"
|
||||||
|
:isDragging="isDragging"
|
||||||
|
:isDraggedOver="isDraggedOver"
|
||||||
|
/>
|
||||||
|
<div v-if="instruction">
|
||||||
|
<slot name="hint" :instruction="instruction" />
|
||||||
|
</div>
|
||||||
|
</TreeItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||||
|
import { useForwardPropsEmits, type FlattenedItem, type TreeItemEmits, type TreeItemProps } from 'radix-vue';
|
||||||
|
import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||||
|
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
|
||||||
|
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
|
||||||
|
|
||||||
|
const props = defineProps<TreeItemProps<T> & FlattenedItem<T>>();
|
||||||
|
const emits = defineEmits<TreeItemEmits<T>>();
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default: (props: {
|
||||||
|
isExpanded: boolean
|
||||||
|
isSelected: boolean
|
||||||
|
isIndeterminate: boolean | undefined
|
||||||
|
isDragging: boolean
|
||||||
|
isDraggedOver: boolean
|
||||||
|
handleToggle: () => void
|
||||||
|
handleSelect: () => void
|
||||||
|
}) => any,
|
||||||
|
hint: (props: {
|
||||||
|
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
|
||||||
|
}) => any,
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const forward = useForwardPropsEmits(props, emits);
|
||||||
|
|
||||||
|
const element = templateRef('el');
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const isDraggedOver = ref(false);
|
||||||
|
const isInitialExpanded = ref(false);
|
||||||
|
const instruction = ref<Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null>(null);
|
||||||
|
|
||||||
|
const mode = computed(() => {
|
||||||
|
if (props.hasChildren)
|
||||||
|
return 'expanded'
|
||||||
|
if (props.index + 1 === props.parentItem?.children?.length)
|
||||||
|
return 'last-in-group'
|
||||||
|
return 'standard'
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
const currentElement = unrefElement(element) as HTMLElement;
|
||||||
|
|
||||||
|
if (!currentElement)
|
||||||
|
return
|
||||||
|
|
||||||
|
const item = { ...props.value, level: props.level, id: props._id }
|
||||||
|
|
||||||
|
const expandItem = () => {
|
||||||
|
if (!element.value?.isExpanded) {
|
||||||
|
element.value?.handleToggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeItem = () => {
|
||||||
|
if (element.value?.isExpanded) {
|
||||||
|
element.value?.handleToggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dndFunction = combine(
|
||||||
|
draggable({
|
||||||
|
element: currentElement,
|
||||||
|
getInitialData: () => item,
|
||||||
|
onDragStart: () => {
|
||||||
|
isDragging.value = true
|
||||||
|
isInitialExpanded.value = element.value?.isExpanded ?? false
|
||||||
|
closeItem()
|
||||||
|
},
|
||||||
|
onDrop: () => {
|
||||||
|
isDragging.value = false
|
||||||
|
if (isInitialExpanded.value)
|
||||||
|
expandItem()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
dropTargetForElements({
|
||||||
|
element: currentElement,
|
||||||
|
getData: ({ input, element }) => {
|
||||||
|
const data = { id: item.id }
|
||||||
|
|
||||||
|
return attachInstruction(data, {
|
||||||
|
input,
|
||||||
|
element,
|
||||||
|
indentPerLevel: 16,
|
||||||
|
currentLevel: props.level,
|
||||||
|
mode: mode.value,
|
||||||
|
block: [],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
canDrop: ({ source }) => {
|
||||||
|
return source.data.id !== item.id
|
||||||
|
},
|
||||||
|
onDrag: ({ self }) => {
|
||||||
|
instruction.value = extractInstruction(self.data) as typeof instruction.value
|
||||||
|
},
|
||||||
|
onDragEnter: ({ source }) => {
|
||||||
|
if (source.data.id !== item.id) {
|
||||||
|
isDraggedOver.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragLeave: () => {
|
||||||
|
isDraggedOver.value = false
|
||||||
|
instruction.value = null
|
||||||
|
},
|
||||||
|
onDrop: ({ location }) => {
|
||||||
|
isDraggedOver.value = false
|
||||||
|
instruction.value = null
|
||||||
|
},
|
||||||
|
getIsSticky: () => true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
monitorForElements({
|
||||||
|
canMonitor: ({ source }) => {
|
||||||
|
return source.data.id !== item.id
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleanup dnd function
|
||||||
|
onCleanup(() => dndFunction())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
73
components/base/DropdownContentRender.vue
Normal file
73
components/base/DropdownContentRender.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<template v-for="(item, idx) of options">
|
||||||
|
<template v-if="item.type === 'item'">
|
||||||
|
<DropdownMenuItem :disabled="item.disabled" :textValue="item.label" @select="item.select" :class="{'!pe-1': item.kbd}" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
|
||||||
|
<Icon v-if="item.icon" :icon="item.icon" class="absolute left-1.5" />
|
||||||
|
<div class="flex flex-1 justify-between">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<span v-if="item.kbd" class="mx-2 text-xs font-mono text-light-70 dark:text-dark-70 relative top-0.5"> {{ item.kbd }} </span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="item.type === 'checkbox'">
|
||||||
|
<DropdownMenuCheckboxItem :disabled="item.disabled" :textValue="item.label" v-model:checked="item.checked" @update:checked="item.select" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
|
||||||
|
<span class="w-6 flex items-center justify-center">
|
||||||
|
<DropdownMenuItemIndicator>
|
||||||
|
<Icon icon="radix-icons:check" />
|
||||||
|
</DropdownMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-1 justify-between">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<span v-if="item.kbd" class="mx-2 text-xs font-mono text-light-70 dark:text-dark-70 relative top-0.5"> {{ item.kbd }} </span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- TODO -->
|
||||||
|
<template v-if="item.type === 'radio'">
|
||||||
|
<DropdownMenuLabel>{{ item.label }}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup @update:model-value="item.change">
|
||||||
|
<DropdownMenuRadioItem v-for="option in item.items" :disabled="(option as any)?.disabled ?? false" :value="(option as any)?.value ?? option">
|
||||||
|
<DropdownMenuItemIndicator>
|
||||||
|
<Icon icon="radix-icons:dot-filled" />
|
||||||
|
</DropdownMenuItemIndicator>
|
||||||
|
<span>{{ (option as any)?.label || option }}</span>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator v-if="idx !== options.length - 1" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="item.type === 'submenu'">
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger class="group cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
|
||||||
|
<Icon v-if="item.icon" :icon="item.icon" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<Icon icon="radix-icons:chevron-right" class="absolute right-1" />
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
|
||||||
|
<DropdownContentRender :options="item.items" />
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="item.type === 'group'">
|
||||||
|
<DropdownMenuLabel class="text-light-70 dark:text-dark-70 text-sm text-center pt-1">{{ item.label }}</DropdownMenuLabel>
|
||||||
|
<DropdownContentRender :options="item.items" />
|
||||||
|
|
||||||
|
<DropdownMenuSeparator v-if="idx !== options.length - 1" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownOption } from './DropdownMenu.vue';
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
const { options } = defineProps<{
|
||||||
|
options: DropdownOption[]
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
58
components/base/DropdownMenu.vue
Normal file
58
components/base/DropdownMenu.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<DropdownMenuRoot>
|
||||||
|
<DropdownMenuTrigger :disabled="disabled"><slot /></DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuContent :align="align" :side="side" class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
|
||||||
|
<DropdownContentRender :options="options" />
|
||||||
|
|
||||||
|
<DropdownMenuArrow class="fill-light-35 dark:fill-dark-35" />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface DropdownItem {
|
||||||
|
type: 'item';
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
select?: () => void;
|
||||||
|
icon?: string;
|
||||||
|
kbd?: string;
|
||||||
|
}
|
||||||
|
export interface DropdownCheckbox {
|
||||||
|
type: 'checkbox';
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
checked?: boolean | Ref<boolean>
|
||||||
|
select?: (state: boolean) => void;
|
||||||
|
kbd?: string;
|
||||||
|
}
|
||||||
|
export interface DropdownRadioGroup {
|
||||||
|
type: 'radio';
|
||||||
|
label: string;
|
||||||
|
value?: string | Ref<string>
|
||||||
|
items: (string | {label: string, value?: string, disabled?: boolean})[];
|
||||||
|
change?: (value: string) => void;
|
||||||
|
}
|
||||||
|
export interface DropdownSubmenu {
|
||||||
|
type: 'submenu';
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
items: DropdownOption[];
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
export interface DropdownGroup {
|
||||||
|
type: 'group';
|
||||||
|
label?: string;
|
||||||
|
items: DropdownOption[];
|
||||||
|
}
|
||||||
|
export type DropdownOption = DropdownItem | DropdownCheckbox | DropdownRadioGroup | DropdownSubmenu | DropdownGroup;
|
||||||
|
const { options, disabled = false, side, align } = defineProps<{
|
||||||
|
options: DropdownOption[]
|
||||||
|
disabled?: boolean
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
align?: 'start' | 'center' | 'end'
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<HoverCardRoot :open-delay="delay">
|
<HoverCardRoot :open-delay="delay" @update:open="(...args) => emits('open', ...args)">
|
||||||
<HoverCardTrigger class="inline-block cursor-help outline-none">
|
<HoverCardTrigger class="inline-block cursor-help outline-none">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardPortal v-if="!disabled">
|
<HoverCardPortal v-if="!disabled">
|
||||||
<HoverCardContent :class="$attrs.class" :side="side" 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 p-5 data-[state=open]:transition-all text-light-100 dark:text-dark-100" >
|
<HoverCardContent :class="$attrs.class" :side="side" :align="align" avoidCollisions :collisionPadding="20" class="max-h-[var(--radix-hover-card-content-available-height)] 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 p-5 data-[state=open]:transition-all text-light-100 dark:text-dark-100" >
|
||||||
<slot name="content"></slot>
|
<slot name="content"></slot>
|
||||||
<HoverCardArrow class="fill-light-35 dark:fill-dark-35" />
|
<HoverCardArrow class="fill-light-35 dark:fill-dark-35" />
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
@@ -13,9 +13,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { delay = 500, disabled = false, side = 'bottom' } = defineProps<{
|
const { delay = 500, disabled = false, side = 'bottom', align = 'center', triggerKey } = defineProps<{
|
||||||
delay?: number
|
delay?: number
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
align?: 'start' | 'center' | 'end'
|
||||||
|
triggerKey?: string
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emits = defineEmits(['open']);
|
||||||
|
const canOpen = ref(true);
|
||||||
|
|
||||||
|
if(triggerKey)
|
||||||
|
{
|
||||||
|
const magicKeys = useMagicKeys();
|
||||||
|
const keys = magicKeys[triggerKey];
|
||||||
|
|
||||||
|
watch(keys, (v) => {
|
||||||
|
canOpen.value = v;
|
||||||
|
}, { immediate: true, });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
3
components/base/Kbd.vue
Normal file
3
components/base/Kbd.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<span class="rounded bg-light-35 dark:bg-dark-35 font-mono text-sm px-1 py-0 select-none" style="box-shadow: black 0 2px 0 1px;"><slot /></span>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<span :class="{'w-6 h-6 border-4 after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 border-2 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 border-[6px] after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}" class="rounded-full border-light-35 dark:border-dark-35 after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin"></span>
|
<span :class="{'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'}" class="after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin"></span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Label class="py-4 flex flex-row justify-center items-center">
|
<Label class="my-2 flex flex-1 items-center justify-between flex-col md:flex-row">
|
||||||
<span>{{ label }}</span>
|
<span class="pb-1 md:p-0">{{ label }}</span>
|
||||||
<SelectRoot v-model="model">
|
<SelectRoot v-model="model" :default-value="defaultValue">
|
||||||
<SelectTrigger :disabled="disabled" class="mx-4 inline-flex min-w-[160px] items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1
|
<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
|
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
|
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
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<SelectPortal :disabled="disabled">
|
<SelectPortal :disabled="disabled">
|
||||||
<SelectContent :position="position"
|
<SelectContent :position="position"
|
||||||
class="min-w-[160px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-40">
|
class="min-w-[160px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-50">
|
||||||
<SelectScrollUpButton>
|
<SelectScrollUpButton>
|
||||||
<Icon icon="radix-icons:chevron-up" />
|
<Icon icon="radix-icons:chevron-up" />
|
||||||
</SelectScrollUpButton>
|
</SelectScrollUpButton>
|
||||||
@@ -31,11 +31,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SelectContent, SelectPortal, SelectRoot, SelectScrollDownButton, SelectScrollUpButton, SelectTrigger, SelectValue, SelectViewport } from 'radix-vue'
|
import { SelectContent, SelectPortal, SelectRoot, SelectScrollDownButton, SelectScrollUpButton, SelectTrigger, SelectValue, SelectViewport } from 'radix-vue'
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
const { placeholder, disabled = false, position = 'popper', label } = defineProps<{
|
const { disabled = false, position = 'popper' } = defineProps<{
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
position?: 'item-aligned' | 'popper'
|
position?: 'item-aligned' | 'popper'
|
||||||
label?: string
|
label?: string
|
||||||
|
defaultValue?: string
|
||||||
}>();
|
}>();
|
||||||
const model = defineModel<string>();
|
const model = defineModel<string>();
|
||||||
</script>
|
</script>
|
||||||
@@ -12,7 +12,7 @@ import { Icon } from '@iconify/vue/dist/iconify.js';
|
|||||||
import { SelectItem, SelectItemIndicator, SelectItemText } from 'radix-vue'
|
import { SelectItem, SelectItemIndicator, SelectItemText } from 'radix-vue'
|
||||||
const { disabled = false, value } = defineProps<{
|
const { disabled = false, value } = defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
value: NonNullable<any>
|
value: NonNullable<string>
|
||||||
label: string
|
label: string
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Label class="flex justify-center items-center my-2">{{ label }}
|
<Label class="flex justify-center items-center my-2">
|
||||||
|
<span class="md:text-base text-sm">{{ label }}</span>
|
||||||
<SwitchRoot v-model:checked="model" :disabled="disabled"
|
<SwitchRoot v-model:checked="model" :disabled="disabled"
|
||||||
class="group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none
|
class="group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none
|
||||||
data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40
|
data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||||
|
|||||||
21
components/base/TagsInput.vue
Normal file
21
components/base/TagsInput.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<TagsInputRoot v-model="model" addOnPaste class="flex gap-2 items-center border p-2 w-full flex-wrap border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10" >
|
||||||
|
<TagsInputItem v-for="item in model" :key="item" :value="item" class="text-light-100 dark:text-dark-100 flex items-center justify-center gap-2 bg-light-20 dark:bg-dark-20 hover:bg-light-35 dark:hover:bg-dark-35 p-1 border border-light-35 dark:border-dark-35">
|
||||||
|
<TagsInputItemText class="text-sm pl-1" />
|
||||||
|
<TagsInputItemDelete asChild>
|
||||||
|
<Icon icon="radix-icons:cross-2" class="w-4 h-4 cursor-pointer" />
|
||||||
|
</TagsInputItemDelete>
|
||||||
|
</TagsInputItem>
|
||||||
|
|
||||||
|
<TagsInputInput :placeholder="placeholder" class="text-sm focus:outline-none flex-1 rounded text-green9 bg-transparent placeholder:text-mauve9 px-1" />
|
||||||
|
</TagsInputRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
const { placeholder } = defineProps<{
|
||||||
|
placeholder?: string
|
||||||
|
}>();
|
||||||
|
const model = defineModel<string[]>();
|
||||||
|
</script>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
class="mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50
|
class="mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50
|
||||||
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||||
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20"
|
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="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs">
|
:type="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs" @change="(e) => emits('change', e)" @input="(e) => emits('input', e)">
|
||||||
</Label>
|
</Label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -16,5 +16,10 @@ const { type = 'text', label, disabled = false, placeholder } = defineProps<{
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
change: [Event]
|
||||||
|
input: [Event]
|
||||||
|
}>();
|
||||||
const model = defineModel<string>();
|
const model = defineModel<string>();
|
||||||
</script>
|
</script>
|
||||||
@@ -1,50 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm" :items="model" :get-key="(item) => item.link ?? item.label">
|
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 text-sm" :items="model" :get-key="getKey" :defaultExpanded="flatten(model)">
|
||||||
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level - 0.5}em` }" v-bind="item.bind" class="flex items-center px-2 outline-none relative cursor-pointer">
|
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level / 2 - 0.5}em` }" v-bind="item.bind" class="flex items-center ps-2 outline-none relative cursor-pointer">
|
||||||
<NuxtLink :href="item.value.link && !item.hasChildren ? { name: 'explore-path', params: { path: item.value.link } } : undefined" no-prefetch class="flex flex-1 items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" :class="{ 'border-s': !item.hasChildren, 'font-medium': item.hasChildren }" active-class="text-accent-blue border-s-2 !border-accent-blue">
|
<slot :isExpanded="isExpanded" :item="item" />
|
||||||
<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 - 1}em` }" />
|
|
||||||
<div class="pl-3 py-1 flex-1 truncate" :data-tag="item.value.tag">
|
|
||||||
{{ item.value.label }}
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</TreeItem>
|
</TreeItem>
|
||||||
</TreeRoot>
|
</TreeRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
const { getKey } = defineProps<{
|
||||||
|
getKey: (val: T) => string
|
||||||
|
}>();
|
||||||
|
|
||||||
interface TreeItem
|
const model = defineModel<T[]>();
|
||||||
|
|
||||||
|
function flatten(arr: T[]): string[]
|
||||||
{
|
{
|
||||||
label: string
|
return arr.filter(e => e.open).flatMap(e => [getKey(e), ...flatten(e.children ?? [])]);
|
||||||
link?: string
|
|
||||||
tag?: string
|
|
||||||
children?: TreeItem[]
|
|
||||||
}
|
}
|
||||||
const model = defineModel<TreeItem[]>();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
[data-tag="canvas"]:after,
|
|
||||||
[data-tag="private"]:after
|
|
||||||
{
|
|
||||||
@apply text-sm;
|
|
||||||
@apply font-normal;
|
|
||||||
@apply float-end;
|
|
||||||
@apply border ;
|
|
||||||
@apply border-light-35 ;
|
|
||||||
@apply dark:border-dark-35;
|
|
||||||
@apply px-1;
|
|
||||||
@apply bg-light-20;
|
|
||||||
@apply dark:bg-dark-20;
|
|
||||||
font-variant: small-caps;
|
|
||||||
}
|
|
||||||
[data-tag="canvas"]:after
|
|
||||||
{
|
|
||||||
content: 'Canvas'
|
|
||||||
}
|
|
||||||
[data-tag="private"]:after
|
|
||||||
{
|
|
||||||
content: 'Privé'
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useDrag, usePinch, useWheel } from '@vueuse/gesture';
|
|
||||||
import type { CanvasContent, CanvasNode } from '~/types/canvas';
|
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
|
||||||
import { clamp } from '#imports';
|
|
||||||
|
|
||||||
interface Props
|
|
||||||
{
|
|
||||||
canvas: CanvasContent;
|
|
||||||
}
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
|
|
||||||
const canvas = useTemplateRef('canvas');
|
|
||||||
|
|
||||||
const reset = (_: MouseEvent) => {
|
|
||||||
zoom.value = minZoom.value;
|
|
||||||
|
|
||||||
dispX.value = 0;
|
|
||||||
dispY.value = 0;
|
|
||||||
}
|
|
||||||
function edgePos(side: 'bottom' | 'top' | 'left' | 'right', pos: { x: number, y: number }, offset: number): { x: number, y: number } {
|
|
||||||
switch (side) {
|
|
||||||
case "left":
|
|
||||||
return {
|
|
||||||
x: pos.x - offset,
|
|
||||||
y: pos.y
|
|
||||||
};
|
|
||||||
case "right":
|
|
||||||
return {
|
|
||||||
x: pos.x + offset,
|
|
||||||
y: pos.y
|
|
||||||
};
|
|
||||||
case "top":
|
|
||||||
return {
|
|
||||||
x: pos.x,
|
|
||||||
y: pos.y - offset
|
|
||||||
};
|
|
||||||
case "bottom":
|
|
||||||
return {
|
|
||||||
x: pos.x,
|
|
||||||
y: pos.y + offset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function getNode(id: string): CanvasNode | undefined
|
|
||||||
{
|
|
||||||
return props.canvas.nodes.find(e => e.id === id);
|
|
||||||
}
|
|
||||||
function posFromDir(e: { minX: number, minY: number, maxX: number, maxY: number }, t: 'bottom' | 'top' | 'left' | 'right'): { x: number, y: number } {
|
|
||||||
switch (t) {
|
|
||||||
case "top":
|
|
||||||
return { x: (e.minX + e.maxX) / 2, y: e.minY };
|
|
||||||
case "right":
|
|
||||||
return { x: e.maxX, y: (e.minY + e.maxY) / 2 };
|
|
||||||
case "bottom":
|
|
||||||
return { x: (e.minX + e.maxX) / 2, y: e.maxY };
|
|
||||||
case "left":
|
|
||||||
return { x: e.minX, y: (e.minY + e.maxY) / 2 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function getBbox(node: CanvasNode): { minX: number, minY: number, maxX: number, maxY: number } {
|
|
||||||
return { minX: node.x, minY: node.y, maxX: node.x + node.width, maxY: node.y + node.height };
|
|
||||||
}
|
|
||||||
function path(from: CanvasNode, fromSide: 'bottom' | 'top' | 'left' | 'right', to: CanvasNode, toSide: 'bottom' | 'top' | 'left' | 'right'): any {
|
|
||||||
if(from === undefined || to === undefined)
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
path: '',
|
|
||||||
from: {},
|
|
||||||
to: {},
|
|
||||||
toSide: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const start = posFromDir(getBbox(from), fromSide), end = posFromDir(getBbox(to), toSide);
|
|
||||||
return bezier(start, fromSide, end, toSide);
|
|
||||||
}
|
|
||||||
function bezier(from: { x: number, y: number }, fromSide: 'bottom' | 'top' | 'left' | 'right', to: { x: number, y: number }, toSide: 'bottom' | 'top' | 'left' | 'right'): any {
|
|
||||||
const r = Math.hypot(from.x - to.x, from.y - to.y), o = clamp(r / 2, 70, 150), a = edgePos(fromSide, from, o), s = edgePos(toSide, to, o);
|
|
||||||
return {
|
|
||||||
path: `M${from.x},${from.y} C${a.x},${a.y} ${s.x},${s.y} ${to.x},${to.y}`,
|
|
||||||
from: from,
|
|
||||||
to: to,
|
|
||||||
side: toSide,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function labelCenter(from: CanvasNode, fromSide: 'bottom' | 'top' | 'left' | 'right', to: CanvasNode, toSide: 'bottom' | 'top' | 'left' | 'right'): string {
|
|
||||||
const start = posFromDir(getBbox(from), fromSide), end = posFromDir(getBbox(to), toSide);
|
|
||||||
const len = Math.hypot(start.x - end.x, start.y - end.y), offset = clamp(len / 2, 70, 150), b = edgePos(fromSide, start, offset), s = edgePos(toSide, end, offset);
|
|
||||||
const center = getCenter(start, end, b, s, 0.5);
|
|
||||||
return `translate(${center.x}px, ${center.y}px)`;
|
|
||||||
}
|
|
||||||
function getCenter(n: { x: number, y: number }, i: { x: number, y: number }, r: { x: number, y: number }, o: { x: number, y: number }, e: number): { x: number, y: number } {
|
|
||||||
const a = 1 - e, s = a * a * a, l = 3 * e * a * a, c = 3 * e * e * a, u = e * e * e;
|
|
||||||
return {
|
|
||||||
x: s * n.x + l * r.x + c * o.x + u * i.x,
|
|
||||||
y: s * n.y + l * r.y + c * o.y + u * i.y
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
|
|
||||||
stroke-light-red
|
|
||||||
stroke-light-orange
|
|
||||||
stroke-light-yellow
|
|
||||||
stroke-light-green
|
|
||||||
stroke-light-cyan
|
|
||||||
stroke-light-purple
|
|
||||||
dark:stroke-dark-red
|
|
||||||
dark:stroke-dark-orange
|
|
||||||
dark:stroke-dark-yellow
|
|
||||||
dark:stroke-dark-green
|
|
||||||
dark:stroke-dark-cyan
|
|
||||||
dark:stroke-dark-purple
|
|
||||||
fill-light-red
|
|
||||||
fill-light-orange
|
|
||||||
fill-light-yellow
|
|
||||||
fill-light-green
|
|
||||||
fill-light-cyan
|
|
||||||
fill-light-purple
|
|
||||||
dark:fill-dark-red
|
|
||||||
dark:fill-dark-orange
|
|
||||||
dark:fill-dark-yellow
|
|
||||||
dark:fill-dark-green
|
|
||||||
dark:fill-dark-cyan
|
|
||||||
dark:fill-dark-purple
|
|
||||||
bg-light-red
|
|
||||||
bg-light-orange
|
|
||||||
bg-light-yellow
|
|
||||||
bg-light-green
|
|
||||||
bg-light-cyan
|
|
||||||
bg-light-purple
|
|
||||||
dark:bg-dark-red
|
|
||||||
dark:bg-dark-orange
|
|
||||||
dark:bg-dark-yellow
|
|
||||||
dark:bg-dark-green
|
|
||||||
dark:bg-dark-cyan
|
|
||||||
dark:bg-dark-purple
|
|
||||||
border-light-red
|
|
||||||
border-light-orange
|
|
||||||
border-light-yellow
|
|
||||||
border-light-green
|
|
||||||
border-light-cyan
|
|
||||||
border-light-purple
|
|
||||||
dark:border-dark-red
|
|
||||||
dark:border-dark-orange
|
|
||||||
dark:border-dark-yellow
|
|
||||||
dark:border-dark-green
|
|
||||||
dark:border-dark-cyan
|
|
||||||
dark:border-dark-purple
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
const dragHandler = useDrag(({ event: Event, delta: [x, y] }: { event: Event, delta: number[] }) => {
|
|
||||||
event?.preventDefault();
|
|
||||||
dispX.value += x / zoom.value;
|
|
||||||
dispY.value += y / zoom.value;
|
|
||||||
}, {
|
|
||||||
domTarget: canvas,
|
|
||||||
eventOptions: { passive: false, }
|
|
||||||
})
|
|
||||||
const pinchHandler = usePinch(({ event: Event, offset: [z] }: { event: Event, offset: number[] }) => {
|
|
||||||
event?.preventDefault();
|
|
||||||
console.log(z);
|
|
||||||
zoom.value = clamp(z / 2048, minZoom.value, 3);
|
|
||||||
}, {
|
|
||||||
domTarget: canvas,
|
|
||||||
eventOptions: { passive: false, }
|
|
||||||
})
|
|
||||||
const wheelHandler = useWheel(({ event: Event, delta: [x, y] }: { event: Event, delta: number[] }) => {
|
|
||||||
event?.preventDefault();
|
|
||||||
zoom.value = clamp(zoom.value + y * -0.001, minZoom.value, 3);
|
|
||||||
}, {
|
|
||||||
domTarget: canvas,
|
|
||||||
eventOptions: { passive: false, }
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Suspense>
|
|
||||||
<template #default>
|
|
||||||
<div id="canvas" ref="canvas" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none"
|
|
||||||
:style="{ '--zoom-multiplier': (1 / Math.pow(zoom, 0.7)) }">
|
|
||||||
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 absolute sm:top-2 top-10 left-2 z-30 overflow-hidden">
|
|
||||||
<Tooltip message="Zoom avant" side="right">
|
|
||||||
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3)" 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" 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 * 0.9, minZoom, 3)" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
|
||||||
<Icon icon="radix-icons:minus" />
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-0 left-0 w-full h-full origin-center pointer-events-none *:pointer-events-auto *:select-none"
|
|
||||||
:style="{transform: `scale(${zoom}) translate(${dispX}px, ${dispY}px)`}">
|
|
||||||
<div>
|
|
||||||
<CanvasNode v-for="node of props.canvas.nodes" :key="node.id" :node="node" :zoom="zoom" />
|
|
||||||
</div>
|
|
||||||
<template v-for="edge of props.canvas.edges">
|
|
||||||
<div :key="edge.id" v-if="edge.label" class="absolute z-10"
|
|
||||||
:style="{ transform: labelCenter(getNode(edge.fromNode)!, edge.fromSide, getNode(edge.toNode)!, edge.toSide) }">
|
|
||||||
<div class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 -translate-x-[50%] -translate-y-[50%]">{{ edge.label }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<svg class="absolute top-0 left-0 overflow-visible w-full h-full origin-top pointer-events-none">
|
|
||||||
<CanvasEdge v-for="edge of props.canvas.edges" :key="edge.id"
|
|
||||||
:path="path(getNode(edge.fromNode)!, edge.fromSide, getNode(edge.toNode)!, edge.toSide)"
|
|
||||||
:color="edge.color" :label="edge.label" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="loading"></div>
|
|
||||||
</template>
|
|
||||||
</Suspense>
|
|
||||||
</template>
|
|
||||||
@@ -1,33 +1,42 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { CanvasColor } from "~/types/canvas";
|
|
||||||
|
|
||||||
type Direction = 'bottom' | 'top' | 'left' | 'right';
|
|
||||||
interface Props
|
|
||||||
{
|
|
||||||
path: {
|
|
||||||
path: string;
|
|
||||||
from: { x: number; y: number };
|
|
||||||
to: { x: number; y: number };
|
|
||||||
side: Direction;
|
|
||||||
};
|
|
||||||
color?: CanvasColor;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const rotation: Record<Direction, string> = {
|
|
||||||
top: "180",
|
|
||||||
bottom: "0",
|
|
||||||
left: "90",
|
|
||||||
right: "270"
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<g :style="{'--canvas-color': color?.hex}" class="z-0">
|
<div class="absolute overflow-visible">
|
||||||
<path :style="`stroke-linecap: butt; stroke-width: calc(3px * var(--zoom-multiplier));`" :class="color?.class ? `stroke-light-${color.class} dark:stroke-dark-${color.class}` : ((color && color?.hex !== undefined) ? 'stroke-[color:var(--canvas-color)]' : 'stroke-light-40 dark:stroke-dark-40')" class="fill-none stroke-[4px]" :d="path.path"></path>
|
<div v-if="edge.label" :style="{ transform: `${labelPos} translate(-50%, -50%)` }" class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20">{{ edge.label }}</div>
|
||||||
<g :style="`transform: translate(${path.to.x}px, ${path.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[path.side]}deg);`">
|
<svg class="absolute top-0 overflow-visible h-px w-px">
|
||||||
<polygon :class="color?.class ? `fill-light-${color.class} dark:fill-dark-${color.class}` : ((color && color?.hex !== undefined) ? 'fill-[color:var(--canvas-color)]' : 'fill-light-40 dark:fill-dark-40')" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
|
<g :style="{'--canvas-color': edge.color?.hex}" class="z-0">
|
||||||
|
<g :style="`transform: translate(${path!.to.x}px, ${path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[path!.side]}deg);`">
|
||||||
|
<polygon :class="style.fill" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
|
||||||
</g>
|
</g>
|
||||||
|
<path :style="`stroke-width: calc(3px * var(--zoom-multiplier));`" style="stroke-linecap: butt;" :class="style.stroke" class="fill-none stroke-[4px]" :d="path!.path"></path>
|
||||||
</g>
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fill-colored
|
||||||
|
{
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
fill: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getPath, labelCenter, rotation } from '#shared/canvas.util';
|
||||||
|
import type { CanvasEdge, CanvasNode } from '~/types/canvas';
|
||||||
|
|
||||||
|
const { edge, nodes } = defineProps<{
|
||||||
|
edge: CanvasEdge
|
||||||
|
nodes: CanvasNode[]
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const from = computed(() => nodes!.find(f => f.id === edge.fromNode));
|
||||||
|
const to = computed(() => nodes!.find(f => f.id === edge.toNode));
|
||||||
|
const path = computed(() => getPath(from.value!, edge.fromSide, to.value!, edge.toSide));
|
||||||
|
const labelPos = computed(() => labelCenter(from.value!, edge.fromSide, to.value!, edge.toSide));
|
||||||
|
|
||||||
|
const style = computed(() => {
|
||||||
|
return edge.color ? edge.color?.class ?
|
||||||
|
{ fill: `fill-light-${edge.color?.class} dark:fill-dark-${edge.color?.class}`, stroke: `stroke-light-${edge.color?.class} dark:stroke-dark-${edge.color?.class}` } :
|
||||||
|
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]` } :
|
||||||
|
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40` }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
98
components/canvas/CanvasEdgeEditor.vue
Normal file
98
components/canvas/CanvasEdgeEditor.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="absolute overflow-visible group" :class="{ 'z-[1]': focusing }">
|
||||||
|
<input v-autofocus v-if="editing" @click="e => e.stopImmediatePropagation()" :style="{ transform: `${labelPos} translate(-50%, -50%)` }" class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20" v-model="edge.label" />
|
||||||
|
<div v-else-if="edge.label" :style="{ transform: `${labelPos} translate(-50%, -50%)` }" class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20" @click.left="select" @dblclick.left="edit">{{ edge.label }}</div>
|
||||||
|
<svg ref="dom" class="absolute top-0 overflow-visible h-px w-px">
|
||||||
|
<g :style="{'--canvas-color': edge.color?.hex}" class="z-0">
|
||||||
|
<g :style="`transform: translate(${path.to.x}px, ${path.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[path.side]}deg);`">
|
||||||
|
<polygon :class="style.fill" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
|
||||||
|
</g>
|
||||||
|
<path :style="`stroke-width: calc(${focusing ? 6 : 3}px * var(--zoom-multiplier));`" style="stroke-linecap: butt;" :class="style.stroke" class="transition-[stroke-width] fill-none stroke-[4px]" :d="path.path"></path>
|
||||||
|
<path style="stroke-width: calc(22px * var(--zoom-multiplier));" class="fill-none transition-opacity z-30 opacity-0 hover:opacity-25" :class="[style.stroke, { 'opacity-25': focusing }]" :d="path.path" @click="select" @dblclick="edit"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span v-if="focusing && !editing" :style="`transform: translate(${path.from.x}px, ${path.from.y}px) translate(-50%, -50%) scale(var(--zoom-multiplier))`" @mousedown.left="(e) => dragEdge(e, 'from')" :class="style.fill" class="hidden group-hover:block z-[31] absolute rounded-full border-2 border-light-70 dark:border-dark-70 bg-light-30 dark:bg-dark-30 w-6 h-6"></span>
|
||||||
|
<span v-if="focusing && !editing" :style="`transform: translate(${path.to.x}px, ${path.to.y}px) translate(-50%, -50%) scale(var(--zoom-multiplier))`" @mousedown.left="(e) => dragEdge(e, 'to')" :class="style.fill" class="hidden group-hover:block z-[31] absolute rounded-full border-2 border-light-70 dark:border-dark-70 bg-light-30 dark:bg-dark-30 w-6 h-6"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fill-colored
|
||||||
|
{
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
fill: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getPath, labelCenter, rotation } from '#shared/canvas.util';
|
||||||
|
import type { Element } from '../CanvasEditor.vue';
|
||||||
|
import type { CanvasEdge, CanvasNode } from '~/types/canvas';
|
||||||
|
|
||||||
|
const { edge, nodes } = defineProps<{
|
||||||
|
edge: CanvasEdge
|
||||||
|
nodes: CanvasNode[]
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select', id: Element): void,
|
||||||
|
(e: 'edit', id: Element): void,
|
||||||
|
(e: 'drag', id: string, _e: MouseEvent, origin: 'from' | 'to'): void,
|
||||||
|
(e: 'input', id: string, text?: string): void,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dom = useTemplateRef('dom');
|
||||||
|
const focusing = ref(false), editing = ref(false);
|
||||||
|
|
||||||
|
const from = computed(() => nodes!.find(f => f.id === edge.fromNode));
|
||||||
|
const to = computed(() => nodes!.find(f => f.id === edge.toNode));
|
||||||
|
const path = computed(() => getPath(from.value!, edge.fromSide, to.value!, edge.toSide)!);
|
||||||
|
const labelPos = computed(() => labelCenter(from.value!, edge.fromSide, to.value!, edge.toSide));
|
||||||
|
|
||||||
|
let oldText = edge.label;
|
||||||
|
|
||||||
|
function select(e: Event) {
|
||||||
|
if(editing.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
focusing.value = true;
|
||||||
|
emit('select', { type: 'edge', id: edge.id });
|
||||||
|
}
|
||||||
|
function edit(e: Event) {
|
||||||
|
oldText = edge.label;
|
||||||
|
|
||||||
|
focusing.value = true;
|
||||||
|
editing.value = true;
|
||||||
|
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
emit('edit', { type: 'edge', id: edge.id });
|
||||||
|
}
|
||||||
|
function dragEdge(e: MouseEvent, origin: 'from' | 'to') {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
|
emit('drag', edge.id, e, origin);
|
||||||
|
}
|
||||||
|
function unselect() {
|
||||||
|
if(editing.value)
|
||||||
|
{
|
||||||
|
const text = edge.label;
|
||||||
|
|
||||||
|
if(text !== oldText)
|
||||||
|
{
|
||||||
|
edge.label = oldText;
|
||||||
|
|
||||||
|
emit('input', edge.id, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focusing.value = false;
|
||||||
|
editing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ unselect, dom, id: edge.id, path });
|
||||||
|
|
||||||
|
const style = computed(() => {
|
||||||
|
return edge.color ? edge.color?.class ?
|
||||||
|
{ fill: `fill-light-${edge.color?.class} dark:fill-dark-${edge.color?.class}`, stroke: `stroke-light-${edge.color?.class} dark:stroke-dark-${edge.color?.class}`, outline: `outline-light-${edge.color?.class} dark:outline-dark-${edge.color?.class}` } :
|
||||||
|
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]`, outline: `outline-[color:var(--canvas-color)]` } :
|
||||||
|
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40`, outline: `outline-light-40 dark:outline-dark-40` }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,27 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<template>
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
<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'}">
|
||||||
import type { CanvasNode } from '~/types/canvas';
|
<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">
|
||||||
interface Props {
|
<div v-if="node.text?.length > 0" class="flex items-center">
|
||||||
node: CanvasNode;
|
<MarkdownRenderer :content="node.text" />
|
||||||
zoom: number;
|
</div>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
const props = defineProps<Props>();
|
<div v-if="node.type === 'group' && node.label !== undefined" :class="style.border" style="max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))" class="origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] mb-2 px-2 py-1 font-thin">{{ node.label }}</div>
|
||||||
|
</div>
|
||||||
const size = Math.max(props.node.width, props.node.height);
|
</template>
|
||||||
const colors = computed(() => {
|
|
||||||
if(props.node.color)
|
|
||||||
{
|
|
||||||
const color = props.node.color;
|
|
||||||
return color?.class ? { bg: `bg-light-${color?.class} dark:bg-dark-${color?.class}`, border: `border-light-${color?.class} dark:border-dark-${color?.class}`} : { bg: `bg-colored`, border: `border-[color:var(--canvas-color)]` };
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return { border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40` };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.bg-colored
|
.bg-colored
|
||||||
@@ -30,23 +18,18 @@ const colors = computed(() => {
|
|||||||
background-color: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
|
background-color: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CanvasNode } from '~/types/canvas';
|
||||||
|
|
||||||
<template>
|
const { node } = defineProps<{
|
||||||
<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'}">
|
node: CanvasNode
|
||||||
<div :class="[colors.border]" class="border-2 bg-light-20 dark:bg-dark-20 overflow-hidden contain-strict w-full h-full flex">
|
zoom: number
|
||||||
<div class="w-full h-full py-2 px-4 flex !bg-opacity-[0.07]" :class="colors.bg">
|
}>();
|
||||||
<template v-if="node.type === 'group' || zoom > Math.min(0.4, 1000 / size)">
|
|
||||||
<div v-if="node.text?.length > 0" class="flex items-center">
|
const style = computed(() => {
|
||||||
<Markdown :content="node.text" />
|
return node.color ? node.color?.class ?
|
||||||
</div>
|
{ bg: `bg-light-${node.color?.class} dark:bg-dark-${node.color?.class}`, border: `border-light-${node.color?.class} dark:border-dark-${node.color?.class}` } :
|
||||||
</template>
|
{ bg: `bg-colored`, border: `border-[color:var(--canvas-color)]` } :
|
||||||
<template v-else>
|
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40` }
|
||||||
<div class="flex flex-1 justify-center items-center bg-light-30 dark:bg-dark-30">
|
});
|
||||||
<Icon icon="radix-icons:text-align-left" class="w-8 h-8"/>
|
</script>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="node.type === 'group' && node.label !== undefined" :class="[colors.border]" style="max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))" class="origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] mb-2 px-2 py-1 font-thin">{{ node.label }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
190
components/canvas/CanvasNodeEditor.vue
Normal file
190
components/canvas/CanvasNodeEditor.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<div class="absolute" ref="dom" :style="{transform: `translate(${node.x}px, ${node.y}px)`, width: `${node.width}px`, height: `${node.height}px`, '--canvas-color': node.color?.hex}" :class="{'-z-10': node.type === 'group', 'z-10': node.type !== 'group'}">
|
||||||
|
<div v-if="!editing || node.type === 'group'" style="outline-style: solid;" :class="[style.border, style.outline, { '!outline-4 cursor-move': focusing }]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4">
|
||||||
|
<div class="w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto" :class="style.bg" @click.left="(e) => { if(node.type !== 'group') selectNode(e) }" @dblclick.left="(e) => { if(node.type !== 'group') editNode(e) }">
|
||||||
|
<div v-if="node.text?.length > 0" class="flex items-center">
|
||||||
|
<MarkdownRenderer :content="node.text" :proses="{ a: FakeA }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="focusing">
|
||||||
|
<span @mousedown.left="(e) => resizeNode(e, 0, 1, 0, -1)" id="n " class="cursor-n-resize absolute -top-3 -right-3 -left-3 h-6 group">
|
||||||
|
<span @mousedown.left="(e) => dragEdge(e, 'top')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -top-1.5 left-1/2 -translate-x-3"></span>
|
||||||
|
</span> <!-- North -->
|
||||||
|
<span @mousedown.left="(e) => resizeNode(e, 0, 0, 0, 1)" id="s " class="cursor-s-resize absolute -bottom-3 -right-3 -left-3 h-6 group">
|
||||||
|
<span @mousedown.left="(e) => dragEdge(e, 'bottom')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -bottom-1.5 left-1/2 -translate-x-3"></span>
|
||||||
|
</span> <!-- South -->
|
||||||
|
<span @mousedown.left="(e) => resizeNode(e, 0, 0, 1, 0)" id="e " class="cursor-e-resize absolute -top-3 -bottom-3 -right-3 w-6 group">
|
||||||
|
<span @mousedown.left="(e) => dragEdge(e, 'right')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -right-1.5 top-1/2 -translate-y-3"></span>
|
||||||
|
</span> <!-- East -->
|
||||||
|
<span @mousedown.left="(e) => resizeNode(e, 1, 0, -1, 0)" id="w " class="cursor-w-resize absolute -top-3 -bottom-3 -left-3 w-6 group">
|
||||||
|
<span @mousedown.left="(e) => dragEdge(e, 'left')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -left-1.5 top-1/2 -translate-y-3"></span>
|
||||||
|
</span> <!-- West -->
|
||||||
|
<span @mousedown.left="(e) => resizeNode(e, 1, 1, -1, -1)" id="nw" class="cursor-nw-resize absolute -top-4 -left-4 w-8 h-8"></span> <!-- North West -->
|
||||||
|
<span @mousedown.left="(e) => resizeNode(e, 0, 1, 1, -1)" id="ne" class="cursor-ne-resize absolute -top-4 -right-4 w-8 h-8"></span> <!-- North East -->
|
||||||
|
<span @mousedown.left="(e) => resizeNode(e, 0, 0, 1, 1)" id="se" class="cursor-se-resize absolute -bottom-4 -right-4 w-8 h-8"></span> <!-- South East -->
|
||||||
|
<span @mousedown.left="(e) => resizeNode(e, 1, 0, -1, 1)" id="sw" class="cursor-sw-resize absolute -bottom-4 -left-4 w-8 h-8"></span> <!-- South West -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else style="outline-style: solid;" :class="[style.border, style.outline, { '!outline-4': focusing }]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 overflow-hidden contain-strict w-full h-full flex py-2" >
|
||||||
|
<FramedEditor v-model="node.text" autofocus :gutters="false"/>
|
||||||
|
</div>
|
||||||
|
<div v-if="!editing && node.type === 'group' && node.label !== undefined" @click.left="(e) => selectNode(e)" @dblclick.left="(e) => editNode(e)" :class="style.border" style="max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))" class="origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] mb-2 px-2 py-1 font-thin">{{ node.label }}</div>
|
||||||
|
<input v-else-if="editing && node.type === 'group'" v-model="node.label" @click="e => e.stopImmediatePropagation()" v-autofocus :class="[style.border, style.outline]" style="max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))" class="origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] appearance-none bg-transparent outline-4 mb-2 px-2 py-1 font-thin min-w-4" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-colored
|
||||||
|
{
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Box, Direction } from '#shared/canvas.util';
|
||||||
|
import type { Element } from '../CanvasEditor.vue';
|
||||||
|
import FakeA from '../prose/FakeA.vue';
|
||||||
|
import type { CanvasNode } from '~/types/canvas';
|
||||||
|
|
||||||
|
const { node, zoom, snap } = defineProps<{
|
||||||
|
node: CanvasNode
|
||||||
|
zoom: number,
|
||||||
|
snap: (activeNode: CanvasNode, resizeHandle?: Box) => Partial<Box>,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select', id: Element): void,
|
||||||
|
(e: 'edit', id: Element): void,
|
||||||
|
(e: 'move', id: string, x: number, y: number): void,
|
||||||
|
(e: 'resize', id: string, x: number, y: number, w: number, h: number): void,
|
||||||
|
(e: 'input', id: string, text: string): void,
|
||||||
|
(e: 'edge', id: string, _e: MouseEvent, side: Direction): void,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dom = useTemplateRef('dom');
|
||||||
|
const focusing = ref(false), editing = ref(false);
|
||||||
|
let oldText = node.type === 'group' ? node.label : node.text;
|
||||||
|
|
||||||
|
function selectNode(e: Event) {
|
||||||
|
if(editing.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
focusing.value = true;
|
||||||
|
emit('select', { type: 'node', id: node.id });
|
||||||
|
|
||||||
|
dom.value?.addEventListener('mousedown', dragstart, { passive: true });
|
||||||
|
}
|
||||||
|
function editNode(e: Event) {
|
||||||
|
focusing.value = true;
|
||||||
|
editing.value = true;
|
||||||
|
|
||||||
|
oldText = node.type === 'group' ? node.label : node.text;
|
||||||
|
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
|
dom.value?.removeEventListener('mousedown', dragstart);
|
||||||
|
emit('edit', { type: 'node', id: node.id });
|
||||||
|
}
|
||||||
|
function resizeNode(e: MouseEvent, x: number, y: number, w: number, h: number) {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
|
const startx = node.x, starty = node.y, startw = node.width, starth = node.height;
|
||||||
|
let realx = node.x, realy = node.y, realw = node.width, realh = node.height;
|
||||||
|
const resizemove = (e: MouseEvent) => {
|
||||||
|
if(e.button !== 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
realx = realx + (e.movementX / zoom) * x;
|
||||||
|
realy = realy + (e.movementY / zoom) * y;
|
||||||
|
realw = Math.max(realw + (e.movementX / zoom) * w, 64);
|
||||||
|
realh = Math.max(realh + (e.movementY / zoom) * h, 64);
|
||||||
|
|
||||||
|
const result = e.altKey ? undefined : snap({ ...node, x: realx, y: realy, width: realw, height: realh }, { x, y, w, h });
|
||||||
|
|
||||||
|
node.x = result?.x ?? realx;
|
||||||
|
node.y = result?.y ?? realy;
|
||||||
|
node.width = result?.w ?? realw;
|
||||||
|
node.height = result?.h ?? realh;
|
||||||
|
};
|
||||||
|
const resizeend = (e: MouseEvent) => {
|
||||||
|
if(e.button !== 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
emit('resize', node.id, node.x - startx, node.y - starty, node.width - startw, node.height - starth);
|
||||||
|
|
||||||
|
window.removeEventListener('mousemove', resizemove);
|
||||||
|
window.removeEventListener('mouseup', resizeend);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', resizemove);
|
||||||
|
window.addEventListener('mouseup', resizeend);
|
||||||
|
}
|
||||||
|
function dragEdge(e: MouseEvent, direction: Direction) {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
|
emit('edge', node.id, e, direction)
|
||||||
|
}
|
||||||
|
function unselect() {
|
||||||
|
if(editing.value)
|
||||||
|
{
|
||||||
|
const text = node.type === 'group' ? node.label : node.text;
|
||||||
|
|
||||||
|
if(text !== oldText)
|
||||||
|
{
|
||||||
|
if(node.type === 'group')
|
||||||
|
node.label = oldText;
|
||||||
|
else
|
||||||
|
node.text = oldText;
|
||||||
|
|
||||||
|
emit('input', node.id, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focusing.value = false;
|
||||||
|
editing.value = false;
|
||||||
|
|
||||||
|
dom.value?.removeEventListener('mousedown', dragstart);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastx = 0, lasty = 0;
|
||||||
|
let realx = 0, realy = 0;
|
||||||
|
const dragmove = (e: MouseEvent) => {
|
||||||
|
if(e.button !== 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
realx += e.movementX / zoom;
|
||||||
|
realy += e.movementY / zoom;
|
||||||
|
|
||||||
|
const result = e.altKey ? undefined : snap({ ...node, x: realx, y: realy });
|
||||||
|
|
||||||
|
node.x = result?.x ?? realx;
|
||||||
|
node.y = result?.y ?? realy;
|
||||||
|
};
|
||||||
|
const dragend = (e: MouseEvent) => {
|
||||||
|
if(e.button !== 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
window.removeEventListener('mousemove', dragmove);
|
||||||
|
window.removeEventListener('mouseup', dragend);
|
||||||
|
|
||||||
|
emit('move', node.id, node.x - lastx, node.y - lasty);
|
||||||
|
};
|
||||||
|
const dragstart = (e: MouseEvent) => {
|
||||||
|
if(e.button !== 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lastx = node.x, lasty = node.y;
|
||||||
|
realx = node.x, realy = node.y;
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', dragmove, { passive: true });
|
||||||
|
window.addEventListener('mouseup', dragend, { passive: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ unselect, dom, id: node.id });
|
||||||
|
|
||||||
|
const style = computed(() => {
|
||||||
|
return node.color ? node.color?.class ?
|
||||||
|
{ bg: `bg-light-${node.color?.class} dark:bg-dark-${node.color?.class}`, border: `border-light-${node.color?.class} dark:border-dark-${node.color?.class}`, outline: `outline-light-${node.color?.class} dark:outline-dark-${node.color?.class}` } :
|
||||||
|
{ bg: `bg-colored`, border: `border-[color:var(--canvas-color)]`, outline: `outline-[color:var(--canvas-color)]` } :
|
||||||
|
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40`, outline: `outline-light-40 dark:outline-dark-40` }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
297
components/page/Canvas.vue
Normal file
297
components/page/Canvas.vue
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type Position } from '#shared/canvas.util';
|
||||||
|
import { hasPermissions } from '~/shared/auth.util';
|
||||||
|
|
||||||
|
const cancelEvent = (e: Event) => e.preventDefault();
|
||||||
|
function center(touches: TouchList): Position
|
||||||
|
{
|
||||||
|
const pos = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
for(const touch of touches)
|
||||||
|
{
|
||||||
|
pos.x += touch.clientX;
|
||||||
|
pos.y += touch.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos.x /= touches.length;
|
||||||
|
pos.y /= touches.length;
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
function distance(touches: TouchList): number
|
||||||
|
{
|
||||||
|
const [A, B] = touches;
|
||||||
|
return Math.hypot(B.clientX - A.clientX, B.clientY - A.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
stroke-light-red
|
||||||
|
stroke-light-orange
|
||||||
|
stroke-light-yellow
|
||||||
|
stroke-light-green
|
||||||
|
stroke-light-cyan
|
||||||
|
stroke-light-purple
|
||||||
|
dark:stroke-dark-red
|
||||||
|
dark:stroke-dark-orange
|
||||||
|
dark:stroke-dark-yellow
|
||||||
|
dark:stroke-dark-green
|
||||||
|
dark:stroke-dark-cyan
|
||||||
|
dark:stroke-dark-purple
|
||||||
|
fill-light-red
|
||||||
|
fill-light-orange
|
||||||
|
fill-light-yellow
|
||||||
|
fill-light-green
|
||||||
|
fill-light-cyan
|
||||||
|
fill-light-purple
|
||||||
|
dark:fill-dark-red
|
||||||
|
dark:fill-dark-orange
|
||||||
|
dark:fill-dark-yellow
|
||||||
|
dark:fill-dark-green
|
||||||
|
dark:fill-dark-cyan
|
||||||
|
dark:fill-dark-purple
|
||||||
|
bg-light-red
|
||||||
|
bg-light-orange
|
||||||
|
bg-light-yellow
|
||||||
|
bg-light-green
|
||||||
|
bg-light-cyan
|
||||||
|
bg-light-purple
|
||||||
|
dark:bg-dark-red
|
||||||
|
dark:bg-dark-orange
|
||||||
|
dark:bg-dark-yellow
|
||||||
|
dark:bg-dark-green
|
||||||
|
dark:bg-dark-cyan
|
||||||
|
dark:bg-dark-purple
|
||||||
|
border-light-red
|
||||||
|
border-light-orange
|
||||||
|
border-light-yellow
|
||||||
|
border-light-green
|
||||||
|
border-light-cyan
|
||||||
|
border-light-purple
|
||||||
|
dark:border-dark-red
|
||||||
|
dark:border-dark-orange
|
||||||
|
dark:border-dark-yellow
|
||||||
|
dark:border-dark-green
|
||||||
|
dark:border-dark-cyan
|
||||||
|
dark:border-dark-purple
|
||||||
|
outline-light-red
|
||||||
|
outline-light-orange
|
||||||
|
outline-light-yellow
|
||||||
|
outline-light-green
|
||||||
|
outline-light-cyan
|
||||||
|
outline-light-purple
|
||||||
|
dark:outline-dark-red
|
||||||
|
dark:outline-dark-orange
|
||||||
|
dark:outline-dark-yellow
|
||||||
|
dark:outline-dark-green
|
||||||
|
dark:outline-dark-cyan
|
||||||
|
dark:outline-dark-purple
|
||||||
|
|
||||||
|
*/
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
import { clamp } from '#shared/general.util';
|
||||||
|
import type { CanvasContent } from '~/types/content';
|
||||||
|
|
||||||
|
const { path } = defineProps<{
|
||||||
|
path: string
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { user } = useUserSession();
|
||||||
|
const { content, get } = useContent();
|
||||||
|
const overview = computed(() => content.value.find(e => e.path === path) as CanvasContent | undefined);
|
||||||
|
const isOwner = computed(() => user.value?.id === overview.value?.owner);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
if(overview.value && !overview.value.content)
|
||||||
|
{
|
||||||
|
loading.value = true;
|
||||||
|
await get(path);
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
const canvas = computed(() => overview.value && overview.value.content ? overview.value.content : undefined);
|
||||||
|
console.log(canvas.value);
|
||||||
|
|
||||||
|
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
|
||||||
|
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef');
|
||||||
|
|
||||||
|
const updateScaleVar = useDebounceFn(() => {
|
||||||
|
if(transformRef.value)
|
||||||
|
{
|
||||||
|
transformRef.value.style.setProperty('--tw-scale', zoom.value.toString());
|
||||||
|
}
|
||||||
|
if(canvasRef.value)
|
||||||
|
{
|
||||||
|
canvasRef.value.style.setProperty('--zoom-multiplier', (1 / Math.pow(zoom.value, 0.7)).toFixed(3));
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const reset = (_: MouseEvent) => {
|
||||||
|
zoom.value = minZoom.value;
|
||||||
|
|
||||||
|
dispX.value = 0;
|
||||||
|
dispY.value = 0;
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
let lastX = 0, lastY = 0, lastDistance = 0;
|
||||||
|
let box = canvasRef.value?.getBoundingClientRect()!;
|
||||||
|
const dragMove = (e: MouseEvent) => {
|
||||||
|
dispX.value = dispX.value - (lastX - e.clientX) / zoom.value;
|
||||||
|
dispY.value = dispY.value - (lastY - e.clientY) / zoom.value;
|
||||||
|
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
};
|
||||||
|
const dragEnd = (e: MouseEvent) => {
|
||||||
|
window.removeEventListener('mouseup', dragEnd);
|
||||||
|
window.removeEventListener('mousemove', dragMove);
|
||||||
|
};
|
||||||
|
canvasRef.value?.addEventListener('mouseenter', () => {
|
||||||
|
window.addEventListener('wheel', cancelEvent, { passive: false });
|
||||||
|
document.addEventListener('gesturestart', cancelEvent);
|
||||||
|
document.addEventListener('gesturechange', cancelEvent);
|
||||||
|
|
||||||
|
canvasRef.value?.addEventListener('mouseleave', () => {
|
||||||
|
window.removeEventListener('wheel', cancelEvent);
|
||||||
|
document.removeEventListener('gesturestart', cancelEvent);
|
||||||
|
document.removeEventListener('gesturechange', cancelEvent);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
window.addEventListener('resize', () => box = canvasRef.value?.getBoundingClientRect()!);
|
||||||
|
canvasRef.value?.addEventListener('mousedown', (e) => {
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', dragEnd, { passive: true });
|
||||||
|
window.addEventListener('mousemove', dragMove, { passive: true });
|
||||||
|
}, { passive: true });
|
||||||
|
canvasRef.value?.addEventListener('wheel', (e) => {
|
||||||
|
if((zoom.value >= 3 && e.deltaY < 0) || (zoom.value <= minZoom.value && e.deltaY > 0))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const diff = Math.exp(e.deltaY * -0.001);
|
||||||
|
const centerX = (box.x + box.width / 2), centerY = (box.y + box.height / 2);
|
||||||
|
const mousex = centerX - e.clientX, mousey = centerY - e.clientY;
|
||||||
|
|
||||||
|
dispX.value = dispX.value - (mousex / (diff * zoom.value) - mousex / zoom.value);
|
||||||
|
dispY.value = dispY.value - (mousey / (diff * zoom.value) - mousey / zoom.value);
|
||||||
|
|
||||||
|
zoom.value = clamp(zoom.value * diff, minZoom.value, 3)
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
}, { passive: true });
|
||||||
|
canvasRef.value?.addEventListener('touchstart', (e) => {
|
||||||
|
({ x: lastX, y: lastY } = center(e.touches));
|
||||||
|
|
||||||
|
if(e.touches.length > 1)
|
||||||
|
{
|
||||||
|
lastDistance = distance(e.touches);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasRef.value?.addEventListener('touchend', touchend, { passive: true });
|
||||||
|
canvasRef.value?.addEventListener('touchcancel', touchcancel, { passive: true });
|
||||||
|
canvasRef.value?.addEventListener('touchmove', touchmove, { passive: true });
|
||||||
|
}, { passive: true });
|
||||||
|
const touchend = (e: TouchEvent) => {
|
||||||
|
if(e.touches.length > 1)
|
||||||
|
{
|
||||||
|
({ x: lastX, y: lastY } = center(e.touches));
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasRef.value?.removeEventListener('touchend', touchend);
|
||||||
|
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
|
||||||
|
canvasRef.value?.removeEventListener('touchmove', touchmove);
|
||||||
|
};
|
||||||
|
const touchcancel = (e: TouchEvent) => {
|
||||||
|
if(e.touches.length > 1)
|
||||||
|
{
|
||||||
|
({ x: lastX, y: lastY } = center(e.touches));
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasRef.value?.removeEventListener('touchend', touchend);
|
||||||
|
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
|
||||||
|
canvasRef.value?.removeEventListener('touchmove', touchmove);
|
||||||
|
};
|
||||||
|
const touchmove = (e: TouchEvent) => {
|
||||||
|
const pos = center(e.touches);
|
||||||
|
dispX.value = dispX.value - (lastX - pos.x) / zoom.value;
|
||||||
|
dispY.value = dispY.value - (lastY - pos.y) / zoom.value;
|
||||||
|
lastX = pos.x;
|
||||||
|
lastY = pos.y;
|
||||||
|
|
||||||
|
if(e.touches.length === 2)
|
||||||
|
{
|
||||||
|
const dist = distance(e.touches);
|
||||||
|
const diff = dist / lastDistance;
|
||||||
|
lastDistance = dist;
|
||||||
|
|
||||||
|
zoom.value = clamp(zoom.value * diff, minZoom.value, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateTransform()
|
||||||
|
{
|
||||||
|
if(transformRef.value)
|
||||||
|
{
|
||||||
|
transformRef.value.style.transform = `scale3d(${zoom.value}, ${zoom.value}, 1) translate3d(${dispX.value}px, ${dispY.value}px, 0)`;
|
||||||
|
}
|
||||||
|
updateScaleVar();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="canvasRef" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none">
|
||||||
|
<div class="flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4">
|
||||||
|
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
|
||||||
|
<Tooltip message="Zoom avant" side="right">
|
||||||
|
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:plus" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip message="Reset" side="right">
|
||||||
|
<div @click="zoom = 1; updateTransform();" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:reload" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip message="Tout contenir" side="right">
|
||||||
|
<div @click="reset" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:corners" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip message="Zoom arrière" side="right">
|
||||||
|
<div @click="zoom = clamp(zoom / 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
|
<Icon icon="radix-icons:minus" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<NuxtLink v-if="overview && isOwner || hasPermissions(user?.permissions ?? [], ['admin', 'editor'])" :to="{ name: 'explore-edit', hash: `#${encodeURIComponent(overview!.path)}` }" class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
|
||||||
|
<Tooltip message="Modifier" side="right">
|
||||||
|
<Icon icon="radix-icons:pencil-1" class="w-8 h-8 p-2" />
|
||||||
|
</Tooltip>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div ref="transformRef" :style="{
|
||||||
|
'transform-origin': 'center center',
|
||||||
|
}" class="h-full">
|
||||||
|
<div v-if="canvas" class="absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none">
|
||||||
|
<div>
|
||||||
|
<CanvasNode v-for="node of canvas.nodes" :key="node.id" ref="nodes" :node="node" :zoom="zoom" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CanvasEdge v-for="edge of canvas.edges" :key="edge.id" ref="edges" :edge="edge" :nodes="canvas.nodes!" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
40
components/page/Markdown.vue
Normal file
40
components/page/Markdown.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { hasPermissions } from '~/shared/auth.util';
|
||||||
|
|
||||||
|
|
||||||
|
const { path } = defineProps<{
|
||||||
|
path: string
|
||||||
|
filter?: string,
|
||||||
|
popover?: boolean
|
||||||
|
}>();
|
||||||
|
const { user } = useUserSession();
|
||||||
|
const { content, get } = useContent();
|
||||||
|
const overview = computed(() => content.value.find(e => e.path === path));
|
||||||
|
const isOwner = computed(() => user.value?.id === overview.value?.owner);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
if(overview.value && !overview.value.content)
|
||||||
|
{
|
||||||
|
loading.value = true;
|
||||||
|
await get(path);
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-1 justify-start items-start flex-col lg:px-16 xl:px-32 2xl:px-64 py-6">
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<template v-else-if="overview">
|
||||||
|
<div v-if="!popover" class="flex flex-1 flex-row justify-between items-center">
|
||||||
|
<ProseH1>{{ overview.title }}</ProseH1>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<NuxtLink :href="{ name: 'explore-edit', hash: '#' + overview.path }" v-if="isOwner || hasPermissions(user?.permissions ?? [], ['admin', 'editor'])"><Button>Modifier</Button></NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MarkdownRenderer v-if="overview.content" :content="overview.content" :filter="filter" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div><ProseH2>Impossible d'afficher le contenu demandé</ProseH2></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,3 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<span class="text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85"><slot v-bind="$attrs"></slot></span>
|
<span>
|
||||||
|
<HoverCard trigger-key="Ctrl" nuxt-client class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview?.type === 'canvas'}" :disabled="!overview">
|
||||||
|
<template #content>
|
||||||
|
<Markdown v-if="overview?.type === 'markdown'" class="!px-6" :path="pathname" :filter="hash.substring(1)" popover />
|
||||||
|
<template v-else-if="overview?.type === 'canvas'"><div class="w-[600px] h-[600px] relative"><Canvas :path="pathname" /></div></template>
|
||||||
</template>
|
</template>
|
||||||
|
<span>
|
||||||
|
<span class="text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85"><slot v-bind="$attrs"></slot></span>
|
||||||
|
</span>
|
||||||
|
</HoverCard>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { parseURL } from 'ufo';
|
||||||
|
|
||||||
|
const { href } = defineProps<{
|
||||||
|
href: string
|
||||||
|
class?: string
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { hash, pathname } = parseURL(href);
|
||||||
|
|
||||||
|
const { content } = useContent();
|
||||||
|
const overview = computed(() => content.value.find(e => e.path === pathname));
|
||||||
|
</script>
|
||||||
30
components/prose/PreviewA.vue
Normal file
30
components/prose/PreviewA.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<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>
|
||||||
@@ -1,60 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLink no-prefetch class="text-accent-blue inline-flex items-center" v-if="data && data[0]"
|
<NuxtLink class="text-accent-blue inline-flex items-center" :to="overview ? { name: 'explore-path', params: { path: overview.path }, hash: decodeURIComponent(hash) } : href" :class="class">
|
||||||
:to="{ name: 'explore-path', params: { path: data[0].path }, hash: hash }" :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">
|
||||||
<HoverCard class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': data[0].type === 'canvas'}">
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<template v-if="data[0].type === 'markdown'">
|
<Markdown v-if="overview?.type === 'markdown'" class="!px-6" :path="decodeURIComponent(pathname)" :filter="hash.substring(1)" popover />
|
||||||
<div class="px-10">
|
<template v-else-if="overview?.type === 'canvas'"><div class="w-[600px] h-[600px] relative"><Canvas :path="decodeURIComponent(pathname)" /></div></template>
|
||||||
<Markdown :content="data[0].content" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="data[0].type === 'canvas'">
|
<span>
|
||||||
<div class="w-[600px] h-[600px] relative">
|
|
||||||
<Canvas :canvas="JSON.parse(data[0].content)" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template #default>
|
|
||||||
<slot v-bind="$attrs"></slot>
|
<slot v-bind="$attrs"></slot>
|
||||||
<Icon class="w-4 h-4 inline-block" v-if="data && data[0] && data[0].type !== 'markdown'" :icon="iconByType[data[0].type]" />
|
<Icon class="w-4 h-4 inline-block" v-if="overview && overview.type !== 'markdown'" :icon="iconByType[overview.type]" />
|
||||||
</template>
|
</span>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink no-prefetch v-else-if="href" :to="href" :class="class" class="text-accent-blue inline-flex items-center">
|
|
||||||
<slot v-bind="$attrs"></slot>
|
|
||||||
<Icon class="w-4 h-4 inline-block" v-if="data && data[0] && data[0].type !== 'markdown'" :height="20" :width="20"
|
|
||||||
:icon="`icons/link-${data[0].type.toLowerCase()}`" />
|
|
||||||
</NuxtLink>
|
|
||||||
<slot :class="class" v-else v-bind="$attrs"></slot>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { parseURL } from 'ufo';
|
import { parseURL } from 'ufo';
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
import { iconByType } from '#shared/general.util';
|
||||||
|
|
||||||
const iconByType: Record<string, string> = {
|
|
||||||
'folder': 'circum:folder-on',
|
|
||||||
'canvas': 'ph:graph-light',
|
|
||||||
'file': 'radix-icons:file',
|
|
||||||
}
|
|
||||||
const { href } = defineProps<{
|
const { href } = defineProps<{
|
||||||
href: string
|
href: string
|
||||||
class?: string
|
class?: string
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { hash, pathname, protocol } = parseURL(href);
|
const { hash, pathname } = parseURL(href);
|
||||||
const data = ref(), loading = ref(false);
|
|
||||||
|
|
||||||
if(!!pathname && !protocol)
|
const { content } = useContent();
|
||||||
{
|
const overview = computed(() => content.value.find(e => e.path === decodeURIComponent(pathname)));
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
data.value = await $fetch(`/api/file`, {
|
|
||||||
query: {
|
|
||||||
search: `%${pathname}`
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch(e) { }
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cm-link {
|
||||||
|
@apply text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,179 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<blockquote ref="el">
|
<blockquote 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" ref="el">
|
||||||
<slot />
|
<slot />
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const attrs = useAttrs(), el = ref<HTMLQuoteElement>(), title = ref<Element | null>(null);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if(el && el.value && attrs.hasOwnProperty("dataCalloutFold"))
|
|
||||||
{
|
|
||||||
title.value = el.value.querySelector('.callout-title');
|
|
||||||
title.value?.addEventListener('click', toggle);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
onUnmounted(() => {
|
|
||||||
title.value?.removeEventListener('click', toggle);
|
|
||||||
})
|
|
||||||
function toggle() {
|
|
||||||
el.value?.classList?.toggle('is-collapsed');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
blockquote:not(.callout)
|
.HyperMD-quote
|
||||||
{
|
|
||||||
@apply ps-4;
|
|
||||||
@apply my-4;
|
|
||||||
@apply relative;
|
|
||||||
@apply before:absolute;
|
|
||||||
@apply before:-top-1;
|
|
||||||
@apply before:-bottom-1;
|
|
||||||
@apply before:left-0;
|
|
||||||
@apply before:w-1;
|
|
||||||
@apply before:bg-light-30;
|
|
||||||
@apply dark:before:bg-dark-30;
|
|
||||||
}
|
|
||||||
blockquote:empty
|
|
||||||
{
|
{
|
||||||
@apply before:hidden;
|
@apply before:hidden;
|
||||||
}
|
}
|
||||||
.callout {
|
.HyperMD-quote.hmd-inactive-line
|
||||||
@apply bg-light-blue;
|
|
||||||
@apply dark:bg-dark-blue;
|
|
||||||
}
|
|
||||||
.callout.is-collapsible .callout-title
|
|
||||||
{
|
{
|
||||||
@apply cursor-pointer;
|
@apply before:block empty:before:!hidden !pb-2 !ps-4 !relative before:!absolute before:!-top-1 before:!-bottom-1 before:!left-0 before:!w-1 before:!bg-none before:!bg-light-30 dark:before:!bg-dark-30;
|
||||||
}
|
}
|
||||||
.callout .fold
|
.HyperMD-quote.HyperMD-header
|
||||||
{
|
{
|
||||||
@apply transition-transform;
|
@apply before:!hidden;
|
||||||
}
|
}
|
||||||
.callout.is-collapsed .fold
|
.hmd-inactive-line .cm-formatting-quote
|
||||||
{
|
{
|
||||||
@apply -rotate-90;
|
@apply !hidden;
|
||||||
}
|
}
|
||||||
.callout.is-collapsed > p
|
.cm-quote
|
||||||
{
|
{
|
||||||
@apply hidden;
|
@apply text-light-100 dark:text-dark-100;
|
||||||
}
|
|
||||||
.callout[datacallout="abstract"],
|
|
||||||
.callout[datacallout="summary"],
|
|
||||||
.callout[datacallout="tldr"] {
|
|
||||||
@apply bg-light-cyan;
|
|
||||||
@apply dark:bg-dark-cyan;
|
|
||||||
@apply text-light-cyan;
|
|
||||||
@apply dark:text-dark-cyan;
|
|
||||||
}
|
|
||||||
.callout[datacallout="info"] {
|
|
||||||
@apply bg-light-blue;
|
|
||||||
@apply dark:bg-dark-blue;
|
|
||||||
@apply text-light-blue;
|
|
||||||
@apply dark:text-dark-blue;
|
|
||||||
}
|
|
||||||
.callout[datacallout="todo"] {
|
|
||||||
@apply bg-light-blue;
|
|
||||||
@apply dark:bg-dark-blue;
|
|
||||||
@apply text-light-blue;
|
|
||||||
@apply dark:text-dark-blue;
|
|
||||||
}
|
|
||||||
.callout[datacallout="important"] {
|
|
||||||
@apply bg-light-cyan;
|
|
||||||
@apply dark:bg-dark-cyan;
|
|
||||||
@apply text-light-cyan;
|
|
||||||
@apply dark:text-dark-cyan;
|
|
||||||
}
|
|
||||||
.callout[datacallout="tip"],
|
|
||||||
.callout[datacallout="hint"] {
|
|
||||||
@apply bg-light-cyan;
|
|
||||||
@apply dark:bg-dark-cyan;
|
|
||||||
@apply text-light-cyan;
|
|
||||||
@apply dark:text-dark-cyan;
|
|
||||||
}
|
|
||||||
.callout[datacallout="success"],
|
|
||||||
.callout[datacallout="check"],
|
|
||||||
.callout[datacallout="done"] {
|
|
||||||
@apply bg-light-green;
|
|
||||||
@apply dark:bg-dark-green;
|
|
||||||
@apply text-light-green;
|
|
||||||
@apply dark:text-dark-green;
|
|
||||||
}
|
|
||||||
.callout[datacallout="question"],
|
|
||||||
.callout[datacallout="help"],
|
|
||||||
.callout[datacallout="faq"] {
|
|
||||||
@apply bg-light-orange;
|
|
||||||
@apply dark:bg-dark-orange;
|
|
||||||
@apply text-light-orange;
|
|
||||||
@apply dark:text-dark-orange;
|
|
||||||
}
|
|
||||||
.callout[datacallout="warning"],
|
|
||||||
.callout[datacallout="caution"],
|
|
||||||
.callout[datacallout="attention"] {
|
|
||||||
@apply bg-light-orange;
|
|
||||||
@apply dark:bg-dark-orange;
|
|
||||||
@apply text-light-orange;
|
|
||||||
@apply dark:text-dark-orange;
|
|
||||||
}
|
|
||||||
.callout[datacallout="failure"],
|
|
||||||
.callout[datacallout="fail"],
|
|
||||||
.callout[datacallout="missing"] {
|
|
||||||
@apply bg-light-red;
|
|
||||||
@apply dark:bg-dark-red;
|
|
||||||
@apply text-light-red;
|
|
||||||
@apply dark:text-dark-red;
|
|
||||||
}
|
|
||||||
.callout[datacallout="danger"],
|
|
||||||
.callout[datacallout="error"] {
|
|
||||||
@apply bg-light-red;
|
|
||||||
@apply dark:bg-dark-red;
|
|
||||||
@apply text-light-red;
|
|
||||||
@apply dark:text-dark-red;
|
|
||||||
}
|
|
||||||
.callout[datacallout="bug"] {
|
|
||||||
@apply bg-light-red;
|
|
||||||
@apply dark:bg-dark-red;
|
|
||||||
@apply text-light-red;
|
|
||||||
@apply dark:text-dark-red;
|
|
||||||
}
|
|
||||||
.callout[datacallout="example"] {
|
|
||||||
@apply bg-light-purple;
|
|
||||||
@apply dark:bg-dark-purple;
|
|
||||||
@apply text-light-purple;
|
|
||||||
@apply dark:text-dark-purple;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callout
|
|
||||||
{
|
|
||||||
@apply overflow-hidden;
|
|
||||||
@apply my-4;
|
|
||||||
@apply p-3;
|
|
||||||
@apply ps-6;
|
|
||||||
@apply bg-blend-lighten;
|
|
||||||
@apply !bg-opacity-25;
|
|
||||||
@apply border-l-4;
|
|
||||||
@apply inline-block;
|
|
||||||
@apply pe-8;
|
|
||||||
}
|
|
||||||
.callout-icon
|
|
||||||
{
|
|
||||||
@apply w-6;
|
|
||||||
@apply h-6;
|
|
||||||
@apply stroke-2;
|
|
||||||
@apply float-start;
|
|
||||||
@apply me-2;
|
|
||||||
}
|
|
||||||
.callout-title-inner
|
|
||||||
{
|
|
||||||
@apply block;
|
|
||||||
@apply font-bold;
|
|
||||||
@apply ps-8;
|
|
||||||
}
|
|
||||||
.callout > p
|
|
||||||
{
|
|
||||||
@apply mt-2;
|
|
||||||
@apply font-semibold;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
146
components/prose/ProseCallout.vue
Normal file
146
components/prose/ProseCallout.vue
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<CollapsibleRoot :disabled="disabled" :defaultOpen="fold === true || fold === undefined" 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" :data-type="type">
|
||||||
|
<CollapsibleTrigger>
|
||||||
|
<div :class="{ 'cursor-pointer': fold !== undefined }" class="flex flex-row items-center justify-start ps-2">
|
||||||
|
<Icon :icon="calloutIconByType[type] ?? defaultCalloutIcon" inline class="w-6 h-6 stroke-2 float-start me-2 flex-shrink-0" />
|
||||||
|
<span v-if="title" class="block font-bold text-start">{{ title }}</span>
|
||||||
|
<Icon icon="radix-icons:caret-right" v-if="fold !== undefined" class="transition-transform group-data-[state=open]:rotate-90 w-6 h-6 mx-6" />
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent class="overflow-hidden data-[state=closed]:animate-[collapseClose_0.2s_ease-in-out] data-[state=open]:animate-[collapseOpen_0.2s_ease-in-out] data-[state=closed]:h-0">
|
||||||
|
<div class="px-2">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</CollapsibleRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
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';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
const { type, title, fold } = defineProps<{
|
||||||
|
type: string;
|
||||||
|
title?: string;
|
||||||
|
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>
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<code><slot /></code>
|
<code><slot /></code>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cm-inline-code
|
||||||
|
{
|
||||||
|
@apply !border-none !bg-transparent !text-light-100 dark:!text-dark-100 !p-0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<h1 :id="parseId(id)" class="text-5xl font-thin mt-3 mb-8 first:pt-0 pt-2 relative lg:right-8 sm:right-4 right-2">
|
<h1 :id="parseId(id)" class="text-5xl font-thin mt-3 mb-8 first:pt-0 pt-2">
|
||||||
<slot />
|
<slot />
|
||||||
</h1>
|
</h1>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { parseId } from '#shared/general.util';
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.HyperMD-header-1
|
||||||
|
{
|
||||||
|
@apply text-5xl pt-4 pb-2 after:hidden;
|
||||||
|
}
|
||||||
|
.HyperMD-header-1 .cm-header
|
||||||
|
{
|
||||||
|
@apply font-thin;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<h2 :id="parseId(id)" class="text-4xl font-semibold mt-3 mb-6 ms-1 first:pt-0 pt-2 relative sm:right-4 right-2">
|
<h2 :id="parseId(id)" class="text-4xl font-semibold mt-3 mb-6 ms-1 first:pt-0 pt-2">
|
||||||
<slot />
|
<slot />
|
||||||
</h2>
|
</h2>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { parseId } from '#shared/general.util';
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
|
|
||||||
const generate = computed(() => props.id)
|
const generate = computed(() => props.id)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.HyperMD-header-2
|
||||||
|
{
|
||||||
|
@apply !text-4xl !pt-4 !pb-2 !ps-1 leading-loose after:hidden;
|
||||||
|
}
|
||||||
|
.HyperMD-header-2 .cm-header
|
||||||
|
{
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { parseId } from '#shared/general.util';
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
|
|
||||||
const generate = computed(() => props.id)
|
const generate = computed(() => props.id)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.HyperMD-header-3
|
||||||
|
{
|
||||||
|
@apply !text-2xl !font-bold !pt-1 after:!hidden;
|
||||||
|
}
|
||||||
|
.HyperMD-header-3 .cm-header
|
||||||
|
{
|
||||||
|
@apply font-bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,5 +5,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { parseId } from '#shared/general.util';
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.HyperMD-header-4
|
||||||
|
{
|
||||||
|
@apply !text-xl font-semibold pt-1 after:hidden;
|
||||||
|
font-variant: small-caps;
|
||||||
|
}
|
||||||
|
.HyperMD-header-4 .cm-header
|
||||||
|
{
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { parseId } from '#shared/general.util';
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
|
|
||||||
const generate = computed(() => props.id)
|
const generate = computed(() => props.id)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { parseId } from '#shared/general.util';
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
|
|
||||||
const generate = computed(() => props.id)
|
const generate = computed(() => props.id)
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<Separator class="border-light-35 dark:border-dark-35 m-4" />
|
<Separator class="border-b border-light-35 dark:border-dark-35 m-4" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.HyperMD-hr
|
||||||
|
{
|
||||||
|
@apply bg-light-35 dark:bg-dark-35 h-px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,3 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<li 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"><slot /></li>
|
<li 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"><slot /></li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.HyperMD-list-line
|
||||||
|
{
|
||||||
|
@apply !py-1;
|
||||||
|
}
|
||||||
|
.HyperMD-list-line.hmd-inactive-line > span
|
||||||
|
{
|
||||||
|
@apply 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;
|
||||||
|
}
|
||||||
|
.hmd-inactive-line .cm-formatting-list
|
||||||
|
{
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
.cm-hmd-list-indent
|
||||||
|
{
|
||||||
|
@apply !hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,3 @@
|
|||||||
<template>
|
<template>
|
||||||
<p><slot /></p>
|
<p><slot /></p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
.text-comment
|
|
||||||
{
|
|
||||||
@apply text-light-50;
|
|
||||||
@apply dark:text-dark-50;
|
|
||||||
@apply text-sm;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
5
components/prose/ProseSmall.vue
Normal file
5
components/prose/ProseSmall.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<small class="text-light-60 dark:text-dark-60 text-sm italic">
|
||||||
|
<slot />
|
||||||
|
</small>
|
||||||
|
</template>
|
||||||
@@ -1,57 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- <HoverPopup @before-show="fetch">
|
|
||||||
<template #content>
|
|
||||||
<Suspense suspensible>
|
|
||||||
<div class="mw-[400px]">
|
|
||||||
<div v-if="fetched === false" class="loading w-[400px] h-[150px]"></div>
|
|
||||||
<template v-else-if="!!data">
|
|
||||||
<div v-if="data.description" class="pb-4 pt-3 px-8">
|
|
||||||
<span class="text-2xl font-semibold">#{{ data.tag }}</span>
|
|
||||||
<Markdown :content="data.description"></Markdown>
|
|
||||||
</div>
|
|
||||||
<div class="h-100 w-100 flex flex-1 flex-col justify-center items-center" v-else>
|
|
||||||
<div class="text-3xl font-extralight tracking-wide text-light-60 dark:text-dark-60">Fichier vide</div>
|
|
||||||
<div class="text-lg text-light-60 dark:text-dark-60">Cette page est vide</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="h-100 w-100 flex flex-1 flex-col justify-center items-center" v-else>
|
|
||||||
<div class="text-3xl font-extralight tracking-wide text-light-60 dark:text-dark-60">Impossible d'afficher</div>
|
|
||||||
<div class="text-lg text-light-60 dark:text-dark-60">Cette page est impossible à traiter</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template #fallback><div class="loading w-[400px] h-[150px]"></div></template>
|
|
||||||
</Suspense>
|
|
||||||
</template>
|
|
||||||
<template #default>
|
|
||||||
<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>
|
|
||||||
</template>
|
|
||||||
</HoverPopup> -->
|
|
||||||
<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">
|
<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>
|
<slot></slot>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- <script setup lang="ts">
|
<style>
|
||||||
import type { Tag } from '~/types/api';
|
.cm-hashtag.cm-hashtag-begin
|
||||||
|
|
||||||
const { tag } = defineProps({
|
|
||||||
tag: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = ref<Tag>(), fetched = ref(false);
|
|
||||||
const route = useRoute();
|
|
||||||
const project = computed(() => parseInt(Array.isArray(route.params.projectId) ? '0' : route.params.projectId));
|
|
||||||
async function fetch()
|
|
||||||
{
|
{
|
||||||
if(fetched.value)
|
@apply bg-accent-blue bg-opacity-10 text-accent-blue text-sm pb-0.5 ps-1 rounded-l-[12px] border border-r-0 border-accent-blue border-opacity-30;
|
||||||
return;
|
|
||||||
|
|
||||||
data.value = await $fetch(`/api/project/${project.value}/tags/${encodeURIComponent(tag)}`);
|
|
||||||
fetched.value = true;
|
|
||||||
}
|
}
|
||||||
</script> -->
|
.cm-hashtag.cm-hashtag-end
|
||||||
|
{
|
||||||
|
@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>
|
||||||
59
composables/useContent.ts
Normal file
59
composables/useContent.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetch(force: boolean) {
|
||||||
|
const content = useContentState();
|
||||||
|
if(content.value.length === 0 || force)
|
||||||
|
content.value = await useRequestFetch()('/api/file/overview');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(path: string) {
|
||||||
|
const content = useContentState()
|
||||||
|
const value = content.value;
|
||||||
|
const item = value.find(e => e.path === path);
|
||||||
|
if(item)
|
||||||
|
{
|
||||||
|
item.content = await useRequestFetch()(`/api/file/content/${encodeURIComponent(path)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChild(arr: TreeItem[], e: ExploreContent): 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export default function useDatabase()
|
|||||||
{
|
{
|
||||||
const database = useRuntimeConfig().database;
|
const database = useRuntimeConfig().database;
|
||||||
const sqlite = new Database(database);
|
const sqlite = new Database(database);
|
||||||
instance = drizzle({ client: sqlite, schema });
|
instance = drizzle({ client: sqlite, schema, /* logger: true */ });
|
||||||
|
|
||||||
instance.run("PRAGMA journal_mode = WAL;");
|
instance.run("PRAGMA journal_mode = WAL;");
|
||||||
instance.run("PRAGMA foreign_keys = true;");
|
instance.run("PRAGMA foreign_keys = true;");
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import RemarkParse from "remark-parse";
|
|||||||
|
|
||||||
import RemarkRehype from 'remark-rehype';
|
import RemarkRehype from 'remark-rehype';
|
||||||
import RemarkOfm from 'remark-ofm';
|
import RemarkOfm from 'remark-ofm';
|
||||||
import RemarkBreaks from 'remark-breaks'
|
|
||||||
import RemarkGfm from 'remark-gfm';
|
import RemarkGfm from 'remark-gfm';
|
||||||
|
import RemarkBreaks from 'remark-breaks';
|
||||||
import RemarkFrontmatter from 'remark-frontmatter';
|
import RemarkFrontmatter from 'remark-frontmatter';
|
||||||
import RehypeRaw from 'rehype-raw';
|
|
||||||
|
|
||||||
export default function useMarkdown(): (md: string) => Root
|
export default function useMarkdown(): (md: string) => Root
|
||||||
{
|
{
|
||||||
@@ -17,8 +16,7 @@ export default function useMarkdown(): (md: string) => Root
|
|||||||
if (!processor)
|
if (!processor)
|
||||||
{
|
{
|
||||||
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]);
|
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]);
|
||||||
processor.use(RemarkRehype, { allowDangerousHtml: true });
|
processor.use(RemarkRehype);
|
||||||
processor.use(RehypeRaw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const processed = processor.runSync(processor.parse(markdown)) as Root;
|
const processed = processor.runSync(processor.parse(markdown)) as Root;
|
||||||
|
|||||||
194
composables/useShortcuts.ts
Normal file
194
composables/useShortcuts.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { ComputedRef, WatchSource } from 'vue'
|
||||||
|
import { logicAnd, logicNot } from '@vueuse/math'
|
||||||
|
import { useEventListener, useDebounceFn, createSharedComposable, useActiveElement } from '@vueuse/core'
|
||||||
|
|
||||||
|
export interface ShortcutConfig {
|
||||||
|
handler: Function
|
||||||
|
usingInput?: string | boolean
|
||||||
|
whenever?: WatchSource<boolean>[]
|
||||||
|
prevent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortcutsConfig {
|
||||||
|
[key: string]: ShortcutConfig | Function
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortcutsOptions {
|
||||||
|
chainDelay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Shortcut {
|
||||||
|
handler: Function
|
||||||
|
condition: ComputedRef<boolean>
|
||||||
|
chained: boolean
|
||||||
|
// KeyboardEvent attributes
|
||||||
|
key: string
|
||||||
|
ctrlKey: boolean
|
||||||
|
metaKey: boolean
|
||||||
|
shiftKey: boolean
|
||||||
|
altKey: boolean
|
||||||
|
// code?: string
|
||||||
|
// keyCode?: number
|
||||||
|
prevent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const chainedShortcutRegex = /^[^-]+.*-.*[^-]+$/
|
||||||
|
const combinedShortcutRegex = /^[^_]+.*_.*[^_]+$/
|
||||||
|
|
||||||
|
export const useShortcuts = (config: ShortcutsConfig, options: ShortcutsOptions = {}) => {
|
||||||
|
const { macOS, usingInput } = _useShortcuts()
|
||||||
|
|
||||||
|
let shortcuts: Shortcut[] = []
|
||||||
|
|
||||||
|
const chainedInputs = ref<string[]>([])
|
||||||
|
const clearChainedInput = () => {
|
||||||
|
chainedInputs.value.splice(0, chainedInputs.value.length)
|
||||||
|
}
|
||||||
|
const debouncedClearChainedInput = useDebounceFn(clearChainedInput, options.chainDelay ?? 800)
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Input autocomplete triggers a keydown event
|
||||||
|
if (!e.key) { return }
|
||||||
|
|
||||||
|
const alphabeticalKey = /^[a-z]{1}$/i.test(e.key)
|
||||||
|
|
||||||
|
let chainedKey
|
||||||
|
chainedInputs.value.push(e.key)
|
||||||
|
// try matching a chained shortcut
|
||||||
|
if (chainedInputs.value.length >= 2) {
|
||||||
|
chainedKey = chainedInputs.value.slice(-2).join('-')
|
||||||
|
|
||||||
|
for (const shortcut of shortcuts.filter(s => s.chained)) {
|
||||||
|
if (shortcut.key !== chainedKey) { continue }
|
||||||
|
|
||||||
|
if (shortcut.condition.value) {
|
||||||
|
e.stopPropagation
|
||||||
|
shortcut.prevent && e.preventDefault()
|
||||||
|
shortcut.handler()
|
||||||
|
}
|
||||||
|
clearChainedInput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// try matching a standard shortcut
|
||||||
|
for (const shortcut of shortcuts.filter(s => !s.chained)) {
|
||||||
|
if (e.key.toLowerCase() !== shortcut.key) { continue }
|
||||||
|
if (e.metaKey !== shortcut.metaKey) { continue }
|
||||||
|
if (e.ctrlKey !== shortcut.ctrlKey) { continue }
|
||||||
|
// shift modifier is only checked in combination with alphabetical keys
|
||||||
|
// (shift with non-alphabetical keys would change the key)
|
||||||
|
if (alphabeticalKey && e.shiftKey !== shortcut.shiftKey) { continue }
|
||||||
|
// alt modifier changes the combined key anyways
|
||||||
|
// if (e.altKey !== shortcut.altKey) { continue }
|
||||||
|
|
||||||
|
if (shortcut.condition.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
shortcut.handler()
|
||||||
|
}
|
||||||
|
clearChainedInput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
debouncedClearChainedInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map config to full detailled shortcuts
|
||||||
|
shortcuts = Object.entries(config).map(([key, shortcutConfig]) => {
|
||||||
|
if (!shortcutConfig) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse key and modifiers
|
||||||
|
let shortcut: Partial<Shortcut>
|
||||||
|
|
||||||
|
if (key.includes('-') && key !== '-' && !key.match(chainedShortcutRegex)?.length) {
|
||||||
|
console.trace(`[Shortcut] Invalid key: "${key}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.includes('_') && key !== '_' && !key.match(combinedShortcutRegex)?.length) {
|
||||||
|
console.trace(`[Shortcut] Invalid key: "${key}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chained = key.includes('-') && key !== '-'
|
||||||
|
if (chained) {
|
||||||
|
shortcut = {
|
||||||
|
key: key.toLowerCase(),
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const keySplit = key.toLowerCase().split('_').map(k => k)
|
||||||
|
shortcut = {
|
||||||
|
key: keySplit.filter(k => !['meta', 'ctrl', 'shift', 'alt'].includes(k)).join('_'),
|
||||||
|
metaKey: keySplit.includes('meta'),
|
||||||
|
ctrlKey: keySplit.includes('ctrl'),
|
||||||
|
shiftKey: keySplit.includes('shift'),
|
||||||
|
altKey: keySplit.includes('alt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortcut.chained = chained
|
||||||
|
|
||||||
|
// Convert Meta to Ctrl for non-MacOS
|
||||||
|
if (!macOS.value && shortcut.metaKey && !shortcut.ctrlKey) {
|
||||||
|
shortcut.metaKey = false
|
||||||
|
shortcut.ctrlKey = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve handler function
|
||||||
|
if (typeof shortcutConfig === 'function') {
|
||||||
|
shortcut.handler = shortcutConfig
|
||||||
|
} else if (typeof shortcutConfig === 'object') {
|
||||||
|
shortcut = { ...shortcut, handler: shortcutConfig.handler, prevent: shortcutConfig.prevent }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortcut.handler) {
|
||||||
|
console.trace('[Shortcut] Invalid value')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create shortcut computed
|
||||||
|
const conditions: ComputedRef<boolean>[] = []
|
||||||
|
if (!(shortcutConfig as ShortcutConfig).usingInput) {
|
||||||
|
conditions.push(logicNot(usingInput))
|
||||||
|
} else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') {
|
||||||
|
conditions.push(computed(() => usingInput.value === (shortcutConfig as ShortcutConfig).usingInput))
|
||||||
|
}
|
||||||
|
shortcut.condition = logicAnd(...conditions, ...((shortcutConfig as ShortcutConfig).whenever || []))
|
||||||
|
|
||||||
|
return shortcut as Shortcut
|
||||||
|
}).filter(Boolean) as Shortcut[]
|
||||||
|
|
||||||
|
useEventListener('keydown', onKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const _useShortcuts = () => {
|
||||||
|
const macOS = computed(() => process.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/))
|
||||||
|
|
||||||
|
const metaSymbol = ref(' ')
|
||||||
|
|
||||||
|
const activeElement = useActiveElement()
|
||||||
|
const usingInput = computed(() => {
|
||||||
|
const usingInput = !!(activeElement.value?.tagName === 'INPUT' || activeElement.value?.tagName === 'TEXTAREA' || activeElement.value?.contentEditable === 'true')
|
||||||
|
|
||||||
|
if (usingInput) {
|
||||||
|
return ((activeElement.value as any)?.name as string) || true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
metaSymbol.value = macOS.value ? '⌘' : 'Ctrl'
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
macOS,
|
||||||
|
metaSymbol,
|
||||||
|
activeElement,
|
||||||
|
usingInput
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { UserSession, UserSessionComposable } from '~/types/auth'
|
import type { UserSession, UserSessionComposable } from '~/types/auth'
|
||||||
|
import { useContent } from './useContent'
|
||||||
|
|
||||||
const useSessionState = () => useState<UserSession>('nuxt-session', () => ({}))
|
const useSessionState = () => useState<UserSession>('nuxt-session', () => ({}))
|
||||||
const useAuthReadyState = () => useState('nuxt-auth-ready', () => false)
|
const useAuthReadyState = () => useState('nuxt-auth-ready', () => false)
|
||||||
|
const useContentFetch = (force: boolean) => useContent().fetch(force);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable to get back the user session and utils around it.
|
* Composable to get back the user session and utils around it.
|
||||||
* @see https://github.com/atinux/nuxt-auth-utils
|
|
||||||
*/
|
*/
|
||||||
export function useUserSession(): UserSessionComposable {
|
export function useUserSession(): UserSessionComposable {
|
||||||
const sessionState = useSessionState()
|
const sessionState = useSessionState()
|
||||||
@@ -13,28 +14,27 @@ export function useUserSession(): UserSessionComposable {
|
|||||||
return {
|
return {
|
||||||
ready: computed(() => authReadyState.value),
|
ready: computed(() => authReadyState.value),
|
||||||
loggedIn: computed(() => Boolean(sessionState.value.user)),
|
loggedIn: computed(() => Boolean(sessionState.value.user)),
|
||||||
user: computed(() => sessionState.value.user || null),
|
user: computed(() => sessionState.value.user ?? null),
|
||||||
session: sessionState,
|
session: sessionState,
|
||||||
fetch,
|
fetch,
|
||||||
clear,
|
clear,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetch() {
|
async function fetch(): Promise<boolean> {
|
||||||
const authReadyState = useAuthReadyState()
|
const authReadyState = useAuthReadyState()
|
||||||
useSessionState().value = await useRequestFetch()('/api/auth/session', {
|
const sessionState = useSessionState()
|
||||||
headers: {
|
const loggedIn = Boolean(sessionState.value.user)
|
||||||
Accept: 'text/json',
|
sessionState.value = await useRequestFetch()('/api/auth/session').catch(() => ({}))
|
||||||
},
|
if (!authReadyState.value)
|
||||||
retry: false,
|
{
|
||||||
}).catch(() => ({}))
|
|
||||||
if (!authReadyState.value) {
|
|
||||||
authReadyState.value = true
|
authReadyState.value = true
|
||||||
}
|
}
|
||||||
|
return loggedIn !== Boolean(sessionState.value.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clear() {
|
async function clear() {
|
||||||
await $fetch('/api/auth/session', { method: 'DELETE' })
|
await useRequestFetch()('/api/auth/session', { method: 'DELETE' })
|
||||||
useSessionState().value = {}
|
useSessionState().value = {}
|
||||||
useRouter().go(0);
|
useRouter().go(0)
|
||||||
}
|
}
|
||||||
BIN
db.sqlite-shm
BIN
db.sqlite-shm
Binary file not shown.
BIN
db.sqlite-wal
BIN
db.sqlite-wal
Binary file not shown.
27
db/schema.ts
27
db/schema.ts
@@ -12,6 +12,8 @@ export const usersTable = sqliteTable("users", {
|
|||||||
export const usersDataTable = sqliteTable("users_data", {
|
export const usersDataTable = sqliteTable("users_data", {
|
||||||
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||||
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
lastTimestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
logCount: int().notNull().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userSessionsTable = sqliteTable("user_sessions", {
|
export const userSessionsTable = sqliteTable("user_sessions", {
|
||||||
@@ -37,12 +39,28 @@ export const explorerContentTable = sqliteTable("explorer_content", {
|
|||||||
path: text().primaryKey(),
|
path: text().primaryKey(),
|
||||||
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||||
title: text().notNull(),
|
title: text().notNull(),
|
||||||
type: text({ enum: ['file', 'folder', 'markdown', 'canvas'] }).notNull(),
|
type: text({ enum: ['file', 'folder', 'markdown', 'canvas', 'map'] }).notNull(),
|
||||||
content: blob({ mode: 'buffer' }),
|
content: blob({ mode: 'buffer' }),
|
||||||
navigable: int({ mode: 'boolean' }).default(true),
|
navigable: int({ mode: 'boolean' }).notNull().default(true),
|
||||||
private: int({ mode: 'boolean' }).default(false),
|
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", {
|
||||||
|
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' }),
|
||||||
|
progress: text({ mode: 'json' }).notNull(),
|
||||||
|
thumbnail: blob(),
|
||||||
|
})
|
||||||
|
|
||||||
export const usersRelation = relations(usersTable, ({ one, many }) => ({
|
export const usersRelation = relations(usersTable, ({ one, many }) => ({
|
||||||
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
|
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
|
||||||
session: many(userSessionsTable),
|
session: many(userSessionsTable),
|
||||||
@@ -61,3 +79,6 @@ export const userPermissionsRelation = relations(userPermissionsTable, ({ one })
|
|||||||
export const explorerContentRelation = relations(explorerContentTable, ({ one }) => ({
|
export const explorerContentRelation = relations(explorerContentTable, ({ one }) => ({
|
||||||
users: one(usersTable, { fields: [explorerContentTable.owner], references: [usersTable.id], }),
|
users: one(usersTable, { fields: [explorerContentTable.owner], references: [usersTable.id], }),
|
||||||
}));
|
}));
|
||||||
|
export const characterRelation = relations(characterTable, ({ one }) => ({
|
||||||
|
users: one(usersTable, { fields: [characterTable.owner], references: [usersTable.id], }),
|
||||||
|
}));
|
||||||
2
drizzle/0003_cultured_skaar.sql
Normal file
2
drizzle/0003_cultured_skaar.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `explorer_content` ADD `order` integer;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `order` ON `explorer_content` (`order`);
|
||||||
21
drizzle/0004_ancient_thunderball.sql
Normal file
21
drizzle/0004_ancient_thunderball.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_explorer_content` (
|
||||||
|
`path` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner` integer NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`content` blob,
|
||||||
|
`navigable` integer DEFAULT true NOT NULL,
|
||||||
|
`private` integer DEFAULT false NOT NULL,
|
||||||
|
`order` integer NOT NULL,
|
||||||
|
`visit` integer DEFAULT 0 NOT NULL,
|
||||||
|
`timestamp` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`owner`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_explorer_content`("path", "owner", "title", "type", "content", "navigable", "private", "order", "visit", "timestamp") SELECT "path", "owner", "title", "type", "content", "navigable", "private", "order", "visit", "timestamp" FROM `explorer_content`;--> statement-breakpoint
|
||||||
|
DROP TABLE `explorer_content`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_explorer_content` RENAME TO `explorer_content`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users_data` ADD `lastTimestamp` integer NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users_data` ADD `logCount` integer DEFAULT 0 NOT NULL;
|
||||||
4
drizzle/0005_panoramic_slayback.sql
Normal file
4
drizzle/0005_panoramic_slayback.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
CREATE TABLE `email_validation` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`timestamp` integer NOT NULL
|
||||||
|
);
|
||||||
7
drizzle/0006_clever_marvex.sql
Normal file
7
drizzle/0006_clever_marvex.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
14
drizzle/0007_tearful_true_believers.sql
Normal file
14
drizzle/0007_tearful_true_believers.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
313
drizzle/meta/0003_snapshot.json
Normal file
313
drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "a1a7b478-d0c3-4fc6-b74a-1a010c1d8ca1",
|
||||||
|
"prevId": "6da7ff20-0db8-4055-a353-bb0ea2fa5e0b",
|
||||||
|
"tables": {
|
||||||
|
"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": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"private": {
|
||||||
|
"name": "private",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"name": "order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"order": {
|
||||||
|
"name": "order",
|
||||||
|
"columns": [
|
||||||
|
"order"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
335
drizzle/meta/0004_snapshot.json
Normal file
335
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "b6acf5d6-d8df-4308-8d4d-55c25741cc4f",
|
||||||
|
"prevId": "a1a7b478-d0c3-4fc6-b74a-1a010c1d8ca1",
|
||||||
|
"tables": {
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
359
drizzle/meta/0005_snapshot.json
Normal file
359
drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "a2731c1f-4150-4423-946e-670d794f8961",
|
||||||
|
"prevId": "b6acf5d6-d8df-4308-8d4d-55c25741cc4f",
|
||||||
|
"tables": {
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
411
drizzle/meta/0006_snapshot.json
Normal file
411
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "4e31a794-f0ae-4c44-a846-6e1bafa4b247",
|
||||||
|
"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": {
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
420
drizzle/meta/0007_snapshot.json
Normal file
420
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
{
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,41 @@
|
|||||||
"when": 1730985155814,
|
"when": 1730985155814,
|
||||||
"tag": "0002_messy_solo",
|
"tag": "0002_messy_solo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1731344368953,
|
||||||
|
"tag": "0003_cultured_skaar",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1732722840534,
|
||||||
|
"tag": "0004_ancient_thunderball",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1734426608563,
|
||||||
|
"tag": "0005_panoramic_slayback",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Erreur {{ error?.statusCode }}</Title>
|
||||||
|
</Head>
|
||||||
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden justify-center items-center flex-col gap-4">
|
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden justify-center items-center flex-col gap-4">
|
||||||
<NuxtRouteAnnouncer/>
|
<NuxtRouteAnnouncer/>
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-4 items-center">
|
||||||
<Icon icon="si:error-line" class="w-12 h-12 text-light-60 dark:text-dark-60"/>
|
<Icon icon="si:error-line" class="w-12 h-12 text-light-60 dark:text-dark-60"/>
|
||||||
<div class="text-3xl">Une erreur est survenue.</div>
|
<div class="text-3xl">Une erreur est survenue.</div>
|
||||||
</div>
|
</div>
|
||||||
<pre class="">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
|
<pre class="text-center text-wrap">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
|
||||||
<NuxtLink :href="{ name: 'index' }"><Button>Revenir en lieu sûr</Button></NuxtLink>
|
<Button @click="handleError">Revenir en lieu sûr</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open">
|
<CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open">
|
||||||
<div class="z-50 md:hidden flex w-full items-center justify-between h-12 border-b border-light-35 dark:border-dark-35">
|
<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">
|
<div class="flex items-center px-2 gap-4">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button icon class="ms-2 !bg-transparent group">
|
<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=open]:hidden" icon="radix-icons:hamburger-menu" />
|
||||||
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
|
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<NuxtLink class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-md:ps-6" aria-label="Accueil" :to="{ path: '/', force: true }">Accueil</NuxtLink>
|
<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 }">
|
||||||
</div>
|
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
||||||
<div class="flex items-center px-2">
|
<Avatar src="/logo.light.svg" class="block dark:hidden" />
|
||||||
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
|
<span class="text-xl max-md:hidden">d[any]</span>
|
||||||
<Tooltip :message="loggedIn ? 'Mon profil' : 'Se connecter'" side="right">
|
|
||||||
<NuxtLink class="" :to="{ name: 'user-profile' }">
|
|
||||||
<div class="hover:border-opacity-70 flex">
|
|
||||||
<Icon :icon="loggedIn ? 'radix-icons:avatar' : 'radix-icons:person'" class="w-7 h-7 p-1" />
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</Tooltip>
|
</div>
|
||||||
|
<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>
|
||||||
|
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70 max-md:hidden" :to="{ name: 'user-register' }">Créer un compte</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-row relative h-screen overflow-hidden">
|
<div class="flex flex-1 flex-row relative h-screen overflow-hidden">
|
||||||
<CollapsibleContent asChild forceMount>
|
<CollapsibleContent asChild forceMount>
|
||||||
<div class="bg-light-0 md:py-11 dark:bg-dark-0 z-40 xl:w-96 md:w-[15em] w-full border-r border-light-30 dark:border-dark-30 flex flex-col justify-between max-md:absolute max-md:-top-0 max-md:-bottom-0 md:left-0 max-md:data-[state=closed]:-left-full max-md:transition-[left] py-8 max-md:z-40 max-md:data-[state=open]:left-0">
|
<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="relative bottom-6 flex flex-col gap-4 xl:px-6 px-3">
|
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
|
||||||
<div class="flex justify-between items-center max-md:hidden">
|
<div v-if="user" class="flex flex-1 py-4 px-2 flex-row flex-1 justify-between items-center">
|
||||||
<NuxtLink class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-md:ps-6" aria-label="Accueil" :to="{ path: '/', force: true }">
|
<NuxtLink :href="{ name: 'character' }" 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">
|
||||||
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
<span class="pl-3 py-1 flex-1 truncate">Mes personnages</span>
|
||||||
<Avatar src="/logo.light.svg" class="block dark:hidden" />
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="flex gap-4 items-center">
|
<NuxtLink v-if="hasPermissions(user.permissions, ['admin', 'editor'])" :to="{ name: 'explore-edit' }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink>
|
||||||
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
|
|
||||||
<Tooltip :message="loggedIn ? 'Mon profil' : 'Se connecter'" side="right">
|
|
||||||
<NuxtLink class="" :to="{ name: 'user-profile' }">
|
|
||||||
<div class="bg-light-20 dark:bg-dark-20 hover:border-opacity-70 flex border p-px border-light-50 dark:border-dark-50">
|
|
||||||
<Icon :icon="loggedIn ? 'radix-icons:avatar' : 'radix-icons:person'" class="w-7 h-7 p-1" />
|
|
||||||
</div>
|
</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>
|
</NuxtLink>
|
||||||
</Tooltip>
|
</template>
|
||||||
|
</Tree>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60">
|
||||||
</div>
|
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
||||||
<Tree v-if="pages" v-model="pages" class="flex-1 xl:px-6 px-3 max-w-full max-h-full overflow-y-auto overflow-x-hidden"/>
|
<p>Copyright Peaceultime - 2025</p>
|
||||||
<div class="xl:px-12 px-6 text-start text-xs text-light-60 dark:text-dark-60 relative top-4">
|
|
||||||
<NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
|
||||||
<p>Copyright Peaceultime - 2024</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@@ -56,20 +64,38 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
import { 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';
|
||||||
|
|
||||||
const open = ref(true);
|
const options = ref<DropdownOption[]>([{
|
||||||
const { loggedIn } = useUserSession();
|
type: 'item',
|
||||||
|
label: 'Mon profil',
|
||||||
|
select: () => useRouter().push({ name: 'user-profile' }),
|
||||||
|
}, {
|
||||||
|
type: 'item',
|
||||||
|
label: 'Deconnexion',
|
||||||
|
select: () => clear(),
|
||||||
|
}]);
|
||||||
|
|
||||||
const { data: pages } = await useLazyFetch('/api/navigation', {
|
const open = ref(false);
|
||||||
transform: transform,
|
const { loggedIn, user, clear } = useUserSession();
|
||||||
});
|
const { fetch } = useContent();
|
||||||
|
|
||||||
watch(useRouter().currentRoute, () => {
|
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);
|
||||||
|
|
||||||
|
watch(route, () => {
|
||||||
open.value = false;
|
open.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function transform(list: any[]): any[]
|
const { tree } = useContent();
|
||||||
|
const pages = computed(() => transform(tree.value));
|
||||||
|
function transform(list: TreeItem[] | undefined): TreeItem[] | undefined
|
||||||
{
|
{
|
||||||
return list?.map(e => ({ label: e.title, children: transform(e.children), link: e.path, tag: e.private ? 'private' : e.type }))
|
return list?.filter(e => e.navigable)?.map(e => ({ ...e, open: path.value?.startsWith(e.path), children: transform(e.children) }));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
<template>
|
<template>
|
||||||
Index
|
<slot></slot>
|
||||||
</template>
|
</template>
|
||||||
28
localhost+1-key.pem
Normal file
28
localhost+1-key.pem
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDjNZPI8RGt6fVV
|
||||||
|
e0403ySKRX1Zh4lPYucvxsojrG86ZS/gm+zHFbTf8kwBR5CUWLuqkNo3vql6W7Go
|
||||||
|
rbPLvbGs1uultilwMRxp0RHx23zecKQMdKA5GiLW+9AI8O23RqNWyF9nJAPdq7TV
|
||||||
|
Dux8OpJXPuT6SWGLBaXcagbe8H/cVMsTqx8FGoOxh9A+MIV6bNaaxvpSR82H9s7i
|
||||||
|
nRSJVxxwHYigrGO5iWvehbjzX0zCD3hzQfZpWWrKa8v8p8+3jkE2dr6l5h1T6Qmi
|
||||||
|
7ZlINiY4vyxgAUM4L9fwSoStWKLf8SnqYOlLXTm7bpBbu5oOQ8yKtJXyat0xx11B
|
||||||
|
FqkqeJmFAgMBAAECggEAcX7U6L5K54YD0AR9J3oDxbI6kFtc4rPz6fCyDqnXEeNz
|
||||||
|
zA33c+dK58cf4k++T+wXKnebGdd6zy04jJrgQjjqpPziz280Od++YrlV7muGb5Ly
|
||||||
|
z2n+kyeUGbHF1IGNLUzy0Kncxie+ap+YAAmpZdDYQw6e0MuRFyHmHTk1X23hYMxl
|
||||||
|
hc8AH5+l+FW0RfgGR8tUFTVc6KbojnKWq2G946NFxHoRwy2/2xEnZu5nciIeUY4O
|
||||||
|
2McnVDlLcomMTt6ScJjZo+fnTyKsWX4yrk3nVPPm7h9Oh4i4QB3/OEqKnlsUCS3u
|
||||||
|
fD3UWlamTF7CETUpuGGj0UaIGFwi3X3SjbuQPZGYzQKBgQDwKmFlL62GyMXsEnI4
|
||||||
|
AVHdnRRCUEgJbX/JVftYdn6psPiCZz+ypr6UKBiyQH0QtxUHxqD2iT2nDR5RmZZR
|
||||||
|
cHhBiJ0KBE3JS3lCm+QcW9r4FOb+V91CycHl4FbnR7LGzJ4ScG0t9F/bJdbyuuiO
|
||||||
|
nwN+sjoNQ55jckaWN5H3kgh8jwKBgQDyMIPuENPUoQksN9ijWkRJPg9qOSF72kEu
|
||||||
|
Ro3wvNdLqC3J3k+Z9Y++diPYOI16nMj/5aTOlWptcr1tzy/rBxXrL1/8uPoGuWGJ
|
||||||
|
OxDrc2lr0rwP6yp8bsmJkhGa1zv5pfisP6L6l/kaRwJ4oe7aUEQUXLndR4D/BIYe
|
||||||
|
PYcOOJs6qwKBgHhUg5/zF3pkteXmCBxPbPkgbrobBzzSBCiYT+qu1B+pb5nGqX+V
|
||||||
|
U/9fZ6BH92GcmYjf2F4tvRop1HsF/O6o71fGXwhZx6+HhSX+fXhH/Zo2vtXIqC+C
|
||||||
|
bwgCMwiGP+ijNMAAXHOd8TkX6G6Nf1+WBGZCXhuvOXiSFRPGm/fyzxW5AoGAQJXp
|
||||||
|
iOIZ63kqXg1ii2V2EmYnbDdiE4pHmZSdI5bofzeRRmUvqyoONEeDFZU3PXx0KbHO
|
||||||
|
+nxkDl3r4E3BRJb2JGrU2StnGcX0GcmToIZ9lZB0MHaRNO/CdRpr8XP2fYPiReUO
|
||||||
|
jG9cscJACXV9oeCH1zpHIph/8QH+1i+oRYWY99MCgYBIMjO4P1t59yCR+hVAs6vB
|
||||||
|
AvY9hcjsrsqqCjuk10BAknGf7sXVcJKXh6ZwOZTq+s3f+jvdCILqomjnTETtvqi3
|
||||||
|
o+lxM5BsI3kih1ZZwmp6l5OZ+XoOHC2enJq6+yvar2cQQ3JXHqgaOeGqvPp79Qgi
|
||||||
|
lUhewf7i9ea3HhsAJVn5zQ==
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
25
localhost+1.pem
Normal file
25
localhost+1.pem
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEPTCCAqWgAwIBAgIRAOY00hX9DwO86FISPVYlPOEwDQYJKoZIhvcNAQELBQAw
|
||||||
|
czEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSQwIgYDVQQLDBtQQy1D
|
||||||
|
TEVNRU5UXFBlYWNlQFBDLUNsZW1lbnQxKzApBgNVBAMMIm1rY2VydCBQQy1DTEVN
|
||||||
|
RU5UXFBlYWNlQFBDLUNsZW1lbnQwHhcNMjUwMTA4MjAzMzU2WhcNMjcwNDA4MTkz
|
||||||
|
MzU2WjBPMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUx
|
||||||
|
JDAiBgNVBAsMG1BDLUNMRU1FTlRcUGVhY2VAUEMtQ2xlbWVudDCCASIwDQYJKoZI
|
||||||
|
hvcNAQEBBQADggEPADCCAQoCggEBAOM1k8jxEa3p9VV7TjTfJIpFfVmHiU9i5y/G
|
||||||
|
yiOsbzplL+Cb7McVtN/yTAFHkJRYu6qQ2je+qXpbsaits8u9sazW66W2KXAxHGnR
|
||||||
|
EfHbfN5wpAx0oDkaItb70Ajw7bdGo1bIX2ckA92rtNUO7Hw6klc+5PpJYYsFpdxq
|
||||||
|
Bt7wf9xUyxOrHwUag7GH0D4whXps1prG+lJHzYf2zuKdFIlXHHAdiKCsY7mJa96F
|
||||||
|
uPNfTMIPeHNB9mlZaspry/ynz7eOQTZ2vqXmHVPpCaLtmUg2Jji/LGABQzgv1/BK
|
||||||
|
hK1Yot/xKepg6UtdObtukFu7mg5DzIq0lfJq3THHXUEWqSp4mYUCAwEAAaNwMG4w
|
||||||
|
DgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaA
|
||||||
|
FDPM3O7GEA4DgJchIK0hiZtf97UjMCYGA1UdEQQfMB2CCWxvY2FsaG9zdIcQAAAA
|
||||||
|
AAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsFAAOCAYEAWLbhajkW3jpXKBNnE4dp
|
||||||
|
fCD1uJ/G8Cuy1poNsXIp2mlhDu4b1mC8mMPwhd01OEXbxZnzLdFiYYy5evxkCODX
|
||||||
|
TlohrWObgCs4iRtSpFT2QOkqLfohdNBtKN6fK2XGbxTqLfW5VStRH2//MzL0P+Cm
|
||||||
|
tUI8P0Tt3Y5jAxrTqmXptlsKkgyhhNUHlXfJCxhvlfvcTvagmCMjf6xBF5ExRH/n
|
||||||
|
GRiWbqSpKQV2PpJObWC8asMJebjkLHQos0v7EobfgbUVVlQRksvlu4EjRZZO3GVD
|
||||||
|
d0+4oUVkG1MHAixNgxvoKrIA2RSYq4D/VBTKvE727SeqySAC4eAaGeD74yG9Tuzr
|
||||||
|
lTBEauqDRlyJX4sS2D1dub655FScNQCdxiB0v+nNuBaJubrGWtXbiBsXYlbHl2cL
|
||||||
|
Nq8rZAobhB0o4DHUIOsY0ygFxqZrZ+3po5gyEb1rbcejTzUoyrh+PCCC6vxbfkOR
|
||||||
|
Db1NyZTKXtVrbOYn6mJ6tsJC2oI+ngciN1mo0eg/ULxB
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import { hasPermissions } from "#shared/auth.util";
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
const { loggedIn, fetch, user } = useUserSession();
|
const { loggedIn, fetch, user } = useUserSession();
|
||||||
|
const { fetch: fetchContent } = useContent();
|
||||||
const meta = to.meta;
|
const meta = to.meta;
|
||||||
|
|
||||||
await fetch();
|
if(await fetch())
|
||||||
|
{
|
||||||
|
fetchContent(true);
|
||||||
|
}
|
||||||
|
|
||||||
if(!!meta.guestsGoesTo && !loggedIn.value)
|
if(!!meta.guestsGoesTo && !loggedIn.value)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
import vuePlugin from 'rollup-plugin-vue'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2024-04-03',
|
compatibilityDate: '2024-04-03',
|
||||||
modules: [
|
modules: [
|
||||||
@@ -7,6 +11,7 @@ export default defineNuxtConfig({
|
|||||||
'@nuxtjs/tailwindcss',
|
'@nuxtjs/tailwindcss',
|
||||||
'@vueuse/nuxt',
|
'@vueuse/nuxt',
|
||||||
'radix-vue/nuxt',
|
'radix-vue/nuxt',
|
||||||
|
'@nuxtjs/sitemap',
|
||||||
],
|
],
|
||||||
tailwindcss: {
|
tailwindcss: {
|
||||||
viewer: false,
|
viewer: false,
|
||||||
@@ -98,8 +103,8 @@ export default defineNuxtConfig({
|
|||||||
100: '#dadada',
|
100: '#dadada',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
purple: '#8a5cf5',
|
purple: '#43A047',
|
||||||
blue: '#53aaf5',
|
blue: '#26C6DA',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,23 +121,64 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
nitro: {
|
nitro: {
|
||||||
|
preset: 'bun',
|
||||||
experimental: {
|
experimental: {
|
||||||
tasks: true,
|
tasks: true,
|
||||||
},
|
},
|
||||||
|
rollupConfig: {
|
||||||
|
external: ['bun'],
|
||||||
|
plugins: [
|
||||||
|
vuePlugin({ include: /\.vue$/, target: 'node' })
|
||||||
|
]
|
||||||
|
},
|
||||||
},
|
},
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
session: {
|
session: {
|
||||||
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
|
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013',
|
||||||
|
maxAge: 60 * 60 * 24 *30,
|
||||||
},
|
},
|
||||||
database: 'db.sqlite'
|
database: 'db.sqlite',
|
||||||
|
mail: {
|
||||||
|
host: '',
|
||||||
|
port: '',
|
||||||
|
proxy: '',
|
||||||
|
user: '',
|
||||||
|
passwd: '',
|
||||||
|
dkim: '',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
rateLimiter: false,
|
rateLimiter: false,
|
||||||
headers: {
|
headers: {
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
"img-src": "'self' data: blob:"
|
"img-src": "'self' data: blob:",
|
||||||
|
"base-uri": "localhost:*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
xssValidator: false,
|
xssValidator: false,
|
||||||
},
|
},
|
||||||
|
sitemap: {
|
||||||
|
exclude: ['/admin/**', '/explore/edit', '/user/mailvalidated', '/user/changing-password', '/user/reset-password'],
|
||||||
|
sources: ['/api/__sitemap__/urls']
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
buildCache: true,
|
||||||
|
componentIslands: {
|
||||||
|
selectiveClient: true,
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
nuxtLink: {
|
||||||
|
prefetchOn: {
|
||||||
|
interaction: false,
|
||||||
|
visibility: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
devServer: {
|
||||||
|
https: {
|
||||||
|
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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
62
package.json
62
package.json
@@ -3,27 +3,57 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "bun i && bunx nuxi cleanup",
|
"predev": "bun i",
|
||||||
"dev": "bunx --bun nuxi dev"
|
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 bunx --bun nuxi dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/vue": "^4.1.2",
|
"@atlaskit/pragmatic-drag-and-drop": "^1.5.0",
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||||
|
"@codemirror/lang-markdown": "^6.3.2",
|
||||||
|
"@iconify/vue": "^4.3.0",
|
||||||
|
"@lezer/highlight": "^1.2.1",
|
||||||
|
"@markdoc/markdoc": "^0.5.1",
|
||||||
"@nuxtjs/color-mode": "^3.5.2",
|
"@nuxtjs/color-mode": "^3.5.2",
|
||||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
"@nuxtjs/sitemap": "^7.2.5",
|
||||||
"@vueuse/nuxt": "^11.1.0",
|
"@nuxtjs/tailwindcss": "^6.13.1",
|
||||||
|
"@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.35.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"nuxt": "^3.14.159",
|
"hast": "^1.0.0",
|
||||||
"nuxt-security": "^2.0.0",
|
"hast-util-heading": "^3.0.0",
|
||||||
"radix-vue": "^1.9.8",
|
"hast-util-heading-rank": "^3.0.0",
|
||||||
"vue": "latest",
|
"lodash.capitalize": "^4.2.1",
|
||||||
"vue-router": "latest",
|
"mdast-util-find-and-replace": "^3.0.2",
|
||||||
"zod": "^3.23.8"
|
"nodemailer": "^6.10.0",
|
||||||
|
"nuxt": "3.15.4",
|
||||||
|
"nuxt-security": "^2.1.5",
|
||||||
|
"radix-vue": "^1.9.15",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
|
"remark-frontmatter": "^5.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-ofm": "link:remark-ofm",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.1",
|
||||||
|
"rollup-plugin-postcss": "^4.0.2",
|
||||||
|
"rollup-plugin-vue": "^6.0.0",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.1.12",
|
"@types/bun": "^1.2.2",
|
||||||
"better-sqlite3": "^11.5.0",
|
"@types/lodash.capitalize": "^4.2.9",
|
||||||
"bun-types": "^1.1.34",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"drizzle-kit": "^0.26.2"
|
"@types/unist": "^3.0.3",
|
||||||
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"bun-types": "^1.2.2",
|
||||||
|
"drizzle-kit": "^0.30.4",
|
||||||
|
"mdast-util-to-string": "^4.0.0",
|
||||||
|
"rehype-stringify": "^10.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,276 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Format bytes as human-readable text.
|
||||||
|
*
|
||||||
|
* @param bytes Number of bytes.
|
||||||
|
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||||
|
* binary (IEC), aka powers of 1024.
|
||||||
|
* @param dp Number of decimal places to display.
|
||||||
|
*
|
||||||
|
* @return Formatted string.
|
||||||
|
*/
|
||||||
|
function textualFileSize(bytes: number, si: boolean = false, dp: number = 2) {
|
||||||
|
const thresh = si ? 1000 : 1024;
|
||||||
|
|
||||||
|
if (Math.abs(bytes) < thresh) {
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
let u = -1;
|
||||||
|
const r = 10**dp;
|
||||||
|
|
||||||
|
do {
|
||||||
|
bytes /= thresh;
|
||||||
|
++u;
|
||||||
|
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||||
|
|
||||||
|
|
||||||
|
return bytes.toFixed(dp) + ' ' + units[u];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { format, iconByType } from '~/shared/general.util';
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
interface File
|
||||||
|
{
|
||||||
|
path: string;
|
||||||
|
owner: number;
|
||||||
|
title: string;
|
||||||
|
type: "file" | "canvas" | "markdown" | 'folder';
|
||||||
|
size: number;
|
||||||
|
navigable: boolean;
|
||||||
|
private: boolean;
|
||||||
|
order: number;
|
||||||
|
visit: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
interface User
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
state: number;
|
||||||
|
session: {
|
||||||
|
id: number;
|
||||||
|
}[];
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
signin: string;
|
||||||
|
lastTimestamp: string;
|
||||||
|
logCount: number;
|
||||||
|
};
|
||||||
|
permission: string[];
|
||||||
|
}
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
rights: ['admin'],
|
rights: ['admin'],
|
||||||
})
|
});
|
||||||
const job = ref<string>('');
|
|
||||||
|
|
||||||
const toaster = useToast();
|
const toaster = useToast();
|
||||||
const data = ref(), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), success = ref(false), err = ref(false), error = ref();
|
|
||||||
async function fetch()
|
|
||||||
{
|
|
||||||
status.value = 'pending';
|
|
||||||
data.value = null;
|
|
||||||
error.value = null;
|
|
||||||
err.value = false;
|
|
||||||
success.value = false;
|
|
||||||
|
|
||||||
|
const { data: users } = useFetch('/api/admin/users', {
|
||||||
|
transform: (users) => {
|
||||||
|
//@ts-ignore
|
||||||
|
users.forEach(e => e.permission = e.permission.map(p => p.permission));
|
||||||
|
//@ts-ignore
|
||||||
|
return users as User[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data: pages } = useFetch('/api/admin/pages');
|
||||||
|
|
||||||
|
const sorter = ref<((a: File, b: File) => number) | null>(null);
|
||||||
|
const sortField = ref<keyof File | null>(null), sortOrder = ref<null | 'asc' | 'desc'>('asc');
|
||||||
|
const sortedPage = ref([...pages.value ?? []]);
|
||||||
|
|
||||||
|
const permissionCopy = ref<string[]>([]);
|
||||||
|
|
||||||
|
watch([sortField, sortOrder, sorter], () => {
|
||||||
|
sortedPage.value = (sorter.value === null ? ([...pages.value ?? []]) : sortedPage.value.sort(sorter.value))
|
||||||
|
}, {
|
||||||
|
immediate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function sort(field: keyof File, type: 'string' | 'number')
|
||||||
|
{
|
||||||
|
if(sortField.value === field)
|
||||||
|
{
|
||||||
|
if(sortOrder.value === 'asc')
|
||||||
|
{
|
||||||
|
sortOrder.value = 'desc';
|
||||||
|
sorter.value = type === 'string' ? (a: File, b: File) => (b[field] as string).localeCompare(a[field] as string) : (a: File, b: File) => (b[field] as number) - (a[field] as number);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sortOrder.value = null;
|
||||||
|
sortField.value = null;
|
||||||
|
sorter.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sortField.value = field;
|
||||||
|
sortOrder.value = 'asc';
|
||||||
|
sorter.value = type === 'string' ? (a: File, b: File) => (a[field] as string).localeCompare(b[field] as string) : (a: File, b: File) => (a[field] as number) - (b[field] as number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function editPermissions(user: User)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
data.value = await $fetch(`/api/admin/jobs/${job.value}`, {
|
await $fetch(`/api/admin/user/${user.id}/permissions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
body: permissionCopy.value,
|
||||||
|
});
|
||||||
|
user.permission = permissionCopy.value;
|
||||||
|
toaster.add({
|
||||||
|
duration: 10000, type: 'success', content: 'Permissions mises à jour.', timer: true,
|
||||||
});
|
});
|
||||||
status.value = 'success';
|
|
||||||
error.value = null;
|
|
||||||
err.value = false;
|
|
||||||
success.value = true;
|
|
||||||
|
|
||||||
toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, });
|
|
||||||
}
|
}
|
||||||
catch(e)
|
catch(e)
|
||||||
{
|
{
|
||||||
status.value = 'error';
|
toaster.add({
|
||||||
error.value = e;
|
duration: 10000, type: 'error', content: (e as any).message, timer: true,
|
||||||
err.value = true;
|
});
|
||||||
success.value = false;
|
}
|
||||||
|
}
|
||||||
|
async function logout(user: User)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await $fetch(`/api/admin/user/${user.id}/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
toaster.add({ duration: 10000, content: error.value, type: 'error', timer: true, });
|
user.session.length = 0;
|
||||||
|
|
||||||
|
toaster.add({
|
||||||
|
duration: 10000, type: 'success', content: 'L\'utilisateur vient d\'être déconnecté.', timer: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
toaster.add({
|
||||||
|
duration: 10000, type: 'error', content: (e as any).message, timer: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head>
|
<Head>
|
||||||
<Title>Administration</Title>
|
<Title>d[any] - Administration</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<div class="flex flex-col justify-start">
|
<div class="flex flex-1 flex-col p-4">
|
||||||
<ProseH2>Administration</ProseH2>
|
<div class="flex flex-row justify-between items-center">
|
||||||
<Select label="Job" v-model="job">
|
<ProseH2 class="text-center flex-1">Administration</ProseH2>
|
||||||
<SelectItem label="Synchroniser" value="sync" />
|
<Button><NuxtLink :to="{ name: 'admin-jobs' }">Jobs</NuxtLink></Button>
|
||||||
<SelectItem label="Nettoyer la base" value="clear" disabled />
|
</div>
|
||||||
<SelectItem label="Reconstruire" value="rebuild" disabled />
|
<div class="flex flex-1 w-full justify-center items-stretch flex-row gap-4">
|
||||||
</Select>
|
<div class="flex-1">
|
||||||
<Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'">
|
<Collapsible v-if=users :label="`Utilisateurs (${users.length})`">
|
||||||
<span>Executer</span>
|
<div class="flex flex-1 mt-2">
|
||||||
</Button>
|
<table class="border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Utilisateur</th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Inscription</th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Dernière connexion</th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Mail</th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Sessions</th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Permissions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="font-normal">
|
||||||
|
<tr v-for="user in users">
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 max-w-32 truncate"><NuxtLink :to="{ name: 'user-id', params: { id: user.id } }" class="hover:text-accent-purple font-bold" :title="user.username">{{ user.username }}</NuxtLink></td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center">{{ format(new Date(user.data.signin), 'dd/MM/yyyy') }}</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center">{{ format(new Date(user.data.lastTimestamp), 'dd/MM/yyyy HH:mm:ss') }}</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center"><Icon :class="{ 'text-light-red dark:text-dark-red': user.state === 0, 'text-light-green dark:text-dark-green': user.state !== 0 }" :icon="user.state === 0 ? `radix-icons:cross-2` : `radix-icons:check`" /></td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1">
|
||||||
|
<DialogRoot>
|
||||||
|
<DialogTrigger asChild><span class="text-accent-blue hover:text-accent-purple font-bold cursor-pointer">{{ user.session.length }}</span></DialogTrigger>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||||
|
<DialogContent
|
||||||
|
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">
|
||||||
|
<DialogTitle class="text-3xl font-light relative -top-2">Deconnecter l'utilisateur ?
|
||||||
|
</DialogTitle>
|
||||||
|
<div class="flex flex-1 justify-end gap-4">
|
||||||
|
<DialogClose asChild><Button>Non</Button></DialogClose>
|
||||||
|
<DialogClose asChild><Button @click="() => logout(user)" class="border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green hover:bg-light-greenBack dark:hover:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green dark:focus:shadow-dark-green">Oui</Button></DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</DialogRoot>
|
||||||
|
</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1">
|
||||||
|
<AlertDialogRoot>
|
||||||
|
<AlertDialogTrigger asChild><span class="text-accent-blue hover:text-accent-purple font-bold" @click="permissionCopy = [...user.permission]">{{ user.permission.length }}</span></AlertDialogTrigger>
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||||
|
<AlertDialogContent
|
||||||
|
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||||
|
<AlertDialogTitle class="text-3xl font-light relative -top-2">Permissions de {{ user.username }}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription><TagsInput v-model="permissionCopy" /></AlertDialogDescription>
|
||||||
|
<div class="flex flex-1 justify-end gap-4">
|
||||||
|
<AlertDialogCancel asChild><Button>Annuler</Button></AlertDialogCancel>
|
||||||
|
<AlertDialogAction asChild><Button @click="() => editPermissions(user)" class="border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green hover:bg-light-greenBack dark:hover:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green dark:focus:shadow-dark-green">Modifier</Button></AlertDialogAction>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</AlertDialogRoot>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<Collapsible v-if=pages :label="`Pages (${pages.length})`">
|
||||||
|
<div class="flex flex-1 mt-2">
|
||||||
|
<table class="border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Page</span><span @click="() => sort('title', 'string')"><Icon :icon="sortField === 'title' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Type</span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Propriétaire</span><span @click="() => sort('owner', 'number')"><Icon :icon="sortField === 'owner' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Status</span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Taille</span><span @click="() => sort('size', 'number')"><Icon :icon="sortField === 'size' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Visites</span><span @click="() => sort('visit', 'number')"><Icon :icon="sortField === 'visit' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Actions</span></div></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="font-normal">
|
||||||
|
<DialogRoot>
|
||||||
|
<tr v-for="page in sortedPage" :id="page.path">
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 max-w-48 truncate"><NuxtLink :to="{ name: 'explore-path', params: { path: page.path } }" class="hover:text-accent-purple font-bold" :title="page.title">{{ page.title }}</NuxtLink></td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1"><Icon :icon="iconByType[page.type]" /></td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center max-w-32 truncate"><span :title=" users?.find(e => e.id === page.owner)?.username ?? 'Inconnu'">{{ users?.find(e => e.id === page.owner)?.username ?? "Inconnu" }}</span></td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 ">
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<span>
|
||||||
|
<Icon v-if="page.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>
|
||||||
|
<Icon v-if="page.navigable" icon="radix-icons:eye-open" />
|
||||||
|
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:eye-none" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center">{{ textualFileSize(page.size) }}</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center">{{ page.visit }}</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center"><div class="flex justify-center items-center"><NuxtLink :to="{ name: 'explore-edit', hash: '#' + page.path }"><Icon icon="radix-icons:pencil-1" /></NuxtLink></div></td>
|
||||||
|
</tr>
|
||||||
|
</DialogRoot>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
92
pages/admin/jobs.vue
Normal file
92
pages/admin/jobs.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const mailSchema = z.object({
|
||||||
|
to: z.string().email(),
|
||||||
|
template: z.string(),
|
||||||
|
data: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaList: Record<string, z.ZodObject<any> | null> = {
|
||||||
|
'pull': null,
|
||||||
|
'push': null,
|
||||||
|
'mail': mailSchema,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
rights: ['admin'],
|
||||||
|
})
|
||||||
|
const job = ref<string>('');
|
||||||
|
|
||||||
|
const toaster = useToast();
|
||||||
|
const payload = reactive<Record<string, any>>({
|
||||||
|
data: JSON.stringify({ username: "Peaceultime", id: 1, timestamp: Date.now() }),
|
||||||
|
to: 'clem31470@gmail.com',
|
||||||
|
});
|
||||||
|
const data = ref(), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), success = ref(false), error = ref<Error | null>();
|
||||||
|
async function fetch()
|
||||||
|
{
|
||||||
|
status.value = 'pending';
|
||||||
|
data.value = null;
|
||||||
|
error.value = null;
|
||||||
|
success.value = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const schema = schemaList[job.value];
|
||||||
|
|
||||||
|
if(schema)
|
||||||
|
{
|
||||||
|
const parsedPayload = schema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.value = await $fetch(`/api/admin/jobs/${job.value}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
status.value = 'success';
|
||||||
|
error.value = null;
|
||||||
|
success.value = true;
|
||||||
|
|
||||||
|
toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, });
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
status.value = 'error';
|
||||||
|
error.value = e as Error;
|
||||||
|
success.value = false;
|
||||||
|
|
||||||
|
toaster.add({ duration: 10000, content: error.value.message, type: 'error', timer: true, });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Administration</Title>
|
||||||
|
</Head>
|
||||||
|
<div class="flex flex-col justify-start items-center p-4">
|
||||||
|
<div class="flex flex-row justify-between items-center gap-8">
|
||||||
|
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||||
|
<ProseH2 class="text-center flex-1">Administration</ProseH2>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row w-full gap-8">
|
||||||
|
<Select label="Job" v-model="job">
|
||||||
|
<SelectItem label="Récupérer les données d'Obsidian" value="pull" />
|
||||||
|
<SelectItem label="Envoyer les données dans Obsidian" value="push" disabled />
|
||||||
|
<SelectItem label="Envoyer un mail de test" value="mail" />
|
||||||
|
</Select>
|
||||||
|
<Select v-if="job === 'mail'" v-model="payload.template" label="Modèle" class="w-full" ><SelectItem label="Inscription" value="registration" /></Select>
|
||||||
|
</div>
|
||||||
|
<div v-if="job === 'mail'" class="flex justify-center items-center flex-col">
|
||||||
|
<TextInput label="Destinataire" class="w-full" v-model="payload.to" />
|
||||||
|
<textarea v-model="payload.data" class="w-[640px] bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none m-2 px-2"></textarea>
|
||||||
|
</div>
|
||||||
|
<Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'">
|
||||||
|
<span>Executer</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
340
pages/character/[id]/edit.client.vue
Normal file
340
pages/character/[id]/edit.client.vue
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<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.spec) text.push(`+${option.spec} spécialisation${option.spec > 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.health) text.push(`+${option.health} PV max.`);
|
||||||
|
if(option.mana) text.push(`+${option.mana} mana max.`);
|
||||||
|
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]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainStatTexts: Record<MainStat, string> = {
|
||||||
|
"strength": "Force",
|
||||||
|
"dexterity": "Dextérité",
|
||||||
|
"constitution": "Constitution",
|
||||||
|
"intelligence": "Intelligence",
|
||||||
|
"curiosity": "Curiosité",
|
||||||
|
"charisma": "Charisme",
|
||||||
|
"psyche": "Psyché",
|
||||||
|
}
|
||||||
|
|
||||||
|
function abilitySpecialFeatures(type: "points" | "max", curiosity: DoubleIndex<TrainingLevel>[], value: number): number
|
||||||
|
{
|
||||||
|
if(type === 'points')
|
||||||
|
{
|
||||||
|
if(curiosity.find(e => e[0] == 7 && e[1] === 0))
|
||||||
|
return Math.max(6, value);
|
||||||
|
if(curiosity.find(e => e[0] == 7 && 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 type { Ability, Character, CharacterConfig, DoubleIndex, Level, MainStat, RaceOption, TrainingLevel, 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>({
|
||||||
|
id: -1,
|
||||||
|
name: '',
|
||||||
|
progress: {
|
||||||
|
training: {
|
||||||
|
strength: [[1, 0], [2, 0], [3, 0], [4, 0]],
|
||||||
|
dexterity: [[1, 0], [2, 0], [3, 0], [4, 0]],
|
||||||
|
constitution: [[1, 0], [2, 0], [3, 0], [4, 0]],
|
||||||
|
intelligence: [[1, 0], [2, 0], [3, 0], [4, 0]],
|
||||||
|
curiosity: [[1, 0], [2, 0], [3, 0], [4, 0]],
|
||||||
|
charisma: [[1, 0], [2, 0], [3, 0], [4, 0]],
|
||||||
|
psyche: [[1, 0], [2, 0], [3, 0], [4, 0]],
|
||||||
|
},
|
||||||
|
level: 1,
|
||||||
|
race: {
|
||||||
|
index: undefined,
|
||||||
|
progress: [[1, 0]],
|
||||||
|
},
|
||||||
|
abilities: {},
|
||||||
|
modifiers: {},
|
||||||
|
notes: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const peopleOpen = ref(false), trainingOpen = ref(false), notesOpen = ref(false), abilityOpen = ref(false), trainingTab = ref(0);
|
||||||
|
const raceOptions = computed(() => data.value.progress.race.index !== undefined ? characterConfig.peoples[data.value.progress.race.index!].options : undefined);
|
||||||
|
const selectedRaceOptions = computed(() => raceOptions !== undefined ? data.value.progress.race.progress!.map(e => raceOptions.value![e[0]][e[1]]) : undefined);
|
||||||
|
const trainingPoints = computed(() => raceOptions.value ? data.value.progress.race.progress?.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.progress.training[e[0] as MainStat])]) as [MainStat, TrainingOption[]][]);
|
||||||
|
const maxTraining = computed(() => Object.entries(data.value.progress.training).reduce((p, v) => { p[v[0]] = 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]] = Math.floor(v[1] / 3) + (data.value.progress.modifiers ? (data.value.progress.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.progress.modifiers ?? {}).reduce((p, v) => p + v, 0));
|
||||||
|
const abilityPoints = computed(() => training.value.flatMap(e => e[1].filter(_e => _e.ability !== undefined)).reduce((p, v) => p + v.ability!, 0));
|
||||||
|
const abilityMax = computed(() => Object.entries(characterConfig.ability).reduce((p, v) => { p[v[0]] = abilitySpecialFeatures("max", data.value.progress.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.progress.abilities ?? {}).reduce((p, v) => p + v[0], 0));
|
||||||
|
|
||||||
|
if(id !== 'new')
|
||||||
|
{
|
||||||
|
const character = await useRequestFetch()(`/api/character/${id}`);
|
||||||
|
|
||||||
|
if(!character)
|
||||||
|
{
|
||||||
|
throw new Error('Donnée du personnage introuvables');
|
||||||
|
}
|
||||||
|
|
||||||
|
data.value = { name: character.name, progress: character.progress } as Character;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRaceOption(level: Level, choice: number)
|
||||||
|
{
|
||||||
|
const character = data.value;
|
||||||
|
if(level > character.progress.level)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if(character.progress.race.progress === undefined)
|
||||||
|
character.progress.race.progress = [[1, 0]];
|
||||||
|
|
||||||
|
if(level == 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for(let i = 1; i < level; i++) //Check previous levels as a requirement
|
||||||
|
{
|
||||||
|
if(!character.progress.race.progress.some(e => e[0] == i))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(character.progress.race.progress.some(e => e[0] === level))
|
||||||
|
{
|
||||||
|
character.progress.race.progress.splice(character.progress.race.progress.findIndex(e => e[0] === level), 1, [level, choice]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
character.progress.race.progress.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.progress.training[stat].some(e => e[0] == i))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(character.progress.training[stat].some(e => e[0] === level))
|
||||||
|
{
|
||||||
|
if(character.progress.training[stat].some(e => e[0] === level && e[1] === choice))
|
||||||
|
{
|
||||||
|
for(let i = 15; i >= level; i --) //Invalidate higher levels
|
||||||
|
{
|
||||||
|
const index = character.progress.training[stat].findIndex(e => e[0] == i);
|
||||||
|
if(index !== -1)
|
||||||
|
character.progress.training[stat].splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
character.progress.training[stat].splice(character.progress.training[stat].findIndex(e => e[0] === level), 1, [level, choice]);
|
||||||
|
}
|
||||||
|
else if(trainingPoints.value && trainingPoints.value > 0)
|
||||||
|
{
|
||||||
|
character.progress.training[stat].push([level, choice]);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.value = character;
|
||||||
|
}
|
||||||
|
function updateLevel()
|
||||||
|
{
|
||||||
|
const character = data.value;
|
||||||
|
|
||||||
|
if(character.progress.race.progress) //Invalidate higher levels
|
||||||
|
{
|
||||||
|
for(let level = 20; level > character.progress.level; level--)
|
||||||
|
{
|
||||||
|
const index = character.progress.race.progress.findIndex(e => e[0] == level);
|
||||||
|
if(index !== -1)
|
||||||
|
character.progress.race.progress.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.value = character;
|
||||||
|
}
|
||||||
|
async function save(leave: boolean)
|
||||||
|
{
|
||||||
|
if(data.value.name === '' || data.value.progress.race.index === undefined || data.value.progress.race.index === -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.progress.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>
|
||||||
|
</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; notesOpen = false; }">
|
||||||
|
<template #label>
|
||||||
|
<span class="font-bold text-xl">Peuple</span>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<div class="m-2 overflow-auto">
|
||||||
|
<Select label="Peuple de votre personnage" :v-model="data.progress.race.index" :default-value="data.progress.race.index?.toString() ?? ''" @update:model-value="(index) => { data.progress.race.index = parseInt(index ?? '-1'); data.progress.race.progress = [[1, 0]]}">
|
||||||
|
<SelectItem v-for="(people, index) of characterConfig.peoples" :label="people.name" :value="index.toString()" :key="index" />
|
||||||
|
</Select>
|
||||||
|
<template v-if="data.progress.race.index !== 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.progress.race.index].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.progress.level - (data.progress.race.progress?.length ?? 0) }}</span>
|
||||||
|
<div class="flex flex-row gap-4 justify-center" v-for="(level, index) of characterConfig.peoples[data.progress.race.index].options" :class="{ 'opacity-30': index > data.progress.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(index as Level, i)" :class="{ 'hover:border-light-60 dark:hover:border-dark-60': index <= data.progress.level, '!border-accent-blue bg-accent-blue bg-opacity-20': data.progress.race.progress?.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.progress.race.index === undefined" @update:model-value="() => { peopleOpen = false; abilityOpen = 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 py-2 bg-light-0 dark:bg-dark-0 z-10 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 flex justify-between z-20 mx-16">
|
||||||
|
<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.progress.modifiers[stat] ?? 0" v-model="data.progress.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, index 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.progress.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.progress.race.index === undefined" @update:model-value="() => { trainingOpen = false;peopleOpen = 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.ability" 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" :max="abilityMax[index]" :default-value="data.progress.abilities[index] ? data.progress.abilities[index][0] : 0" @update:model-value="(value) => { data.progress.abilities[index] = [value, data.progress.abilities[index] ? data.progress.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="notesOpen" @update:model-value="() => { trainingOpen = false; peopleOpen = false; abilityOpen = 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.progress.notes" />
|
||||||
|
</template>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
142
pages/character/[id]/index.client.vue
Normal file
142
pages/character/[id]/index.client.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
const id = useRouter().currentRoute.value.params.id;
|
||||||
|
const { user } = useUserSession();
|
||||||
|
const { data: character, status, error } = await useAsyncData(() => useRequestFetch()(`/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 flex-row gap-6 items-center justify-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" : config.peoples[character.race].name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="h-full border-l border-light-30 dark:border-dark-30"></span>
|
||||||
|
<span>PV: {{ character.health }}</span>
|
||||||
|
<span>Mana: {{ character.mana }}</span>
|
||||||
|
</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="flex flex-row gap-4 items-center justify-center border-b border-light-30 dark:border-dark-30">
|
||||||
|
<div class="flex relative ps-4">
|
||||||
|
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">+{{ character.modifier.strength }}</span><span>Force</span></div>
|
||||||
|
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">+{{ character.modifier.dexterity }}</span><span>Dextérité</span></div>
|
||||||
|
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">+{{ character.modifier.constitution }}</span><span>Constitution</span></div>
|
||||||
|
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">+{{ character.modifier.intelligence }}</span><span>Intelligence</span></div>
|
||||||
|
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">+{{ character.modifier.curiosity }}</span><span>Curiosité</span></div>
|
||||||
|
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">+{{ character.modifier.charisma }}</span><span>Charisme</span></div>
|
||||||
|
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">+{{ character.modifier.psyche }}</span><span>Psyché</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex relative border-l border-light-30 dark:border-dark-30 ps-4">
|
||||||
|
<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">
|
||||||
|
<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-8 py-8 w-80">
|
||||||
|
<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">Compétences</span>
|
||||||
|
<div class="grid grid-cols-3 gap-1">
|
||||||
|
<div class="flex flex-col px-2 items-center" v-for="(value, ability) of character.abilities"><span class="font-bold">+{{ value }}</span><span>{{ config.ability[ability].name }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col border-l border-light-30 dark:border-dark-30 ps-8 gap-4 py-8 max-w-[80rem]">
|
||||||
|
<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 class="flex flex-col">
|
||||||
|
<span class="text-lg font-semibold border-b border-light-30 dark:border-dark-30">Notes</span>
|
||||||
|
<MarkdownRenderer :content="character.notes" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Erreur</Title>
|
||||||
|
</Head>
|
||||||
|
<div>Erreur de chargement</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
61
pages/character/index.client.vue
Normal file
61
pages/character/index.client.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Progression } from '~/types/character';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
guestsGoesTo: '/user/login',
|
||||||
|
})
|
||||||
|
const { add } = useToast();
|
||||||
|
const { user } = useUserSession();
|
||||||
|
const loading = ref(true);
|
||||||
|
const characters = ref<Array<{ id: number, name: string, progress: Progression }>>([]);
|
||||||
|
characters.value = await useRequestFetch()('/api/character');
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
async function deleteCharacter(id: number)
|
||||||
|
{
|
||||||
|
loading.value = true;
|
||||||
|
await useRequestFetch()(`/api/character/${id}`, { method: 'delete' });
|
||||||
|
loading.value = false;
|
||||||
|
add({ content: 'Personnage supprimé', type: 'info', duration: 25000, timer: true, });
|
||||||
|
characters.value = characters.value?.filter(e => e.id !== id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Mes personnages</Title>
|
||||||
|
</Head>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<NuxtLink v-if="user?.state === 1" :to="{ name: 'character-id-edit', params: { id: 'new' } }" class="flex align-center justify-center"><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 v-if="loading" class="flex flex-1 justify-center align-center">
|
||||||
|
<Loading size="large" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid p-6 grid-cols-4 gap-4">
|
||||||
|
<div class="border border-light-30 dark:border-dark-30 p-1 flex flex-row gap-4" v-for="character of characters">
|
||||||
|
<Avatar size="large" icon="radix-icons:person" src="" class="m-2" />
|
||||||
|
<div class="flex flex-col justify-between w-64">
|
||||||
|
<NuxtLink class="flex-1 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="flex-1 text-sm truncate">Niveau {{ character.progress.level }}</span>
|
||||||
|
<div class="flex flex-row gap-8">
|
||||||
|
<NuxtLink class="font-bold text-accent-blue hover:text-opacity-50" :to="{ name: 'character-id-edit', params: { id: character.id } }">Editer</NuxtLink>
|
||||||
|
<AlertDialogRoot>
|
||||||
|
<AlertDialogTrigger asChild><span class="font-bold text-light-red dark:text-dark-red hover:text-opacity-50 cursor-pointer">Supprimer</span></AlertDialogTrigger>
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||||
|
<AlertDialogContent
|
||||||
|
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||||
|
<AlertDialogTitle class="text-3xl font-light relative -top-2">Supprimer {{ character.name }} ?</AlertDialogTitle>
|
||||||
|
<div class="flex flex-1 justify-end gap-4">
|
||||||
|
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
|
||||||
|
<AlertDialogAction asChild><Button @click="() => deleteCharacter(character.id)" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</AlertDialogRoot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Head>
|
|
||||||
<Title>Editeur</Title>
|
|
||||||
</Head>
|
|
||||||
<Editor v-model="model" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
definePageMeta({
|
|
||||||
rights: ['admin', 'editor'],
|
|
||||||
})
|
|
||||||
const model = defineModel<string>({
|
|
||||||
default: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam quis orci et est malesuada vulputate. Aenean sagittis congue eros, non feugiat metus bibendum consectetur. Duis volutpat leo nisi, in maximus nulla rhoncus ac. Sed scelerisque ipsum et volutpat dignissim. Integer massa nibh, imperdiet quis condimentum vitae, imperdiet quis quam. Cras pretium ex eget hendrerit porttitor. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque rutrum scelerisque quam, sit amet malesuada mi convallis aliquam. Curabitur eget dolor in diam scelerisque tincidunt at et sapien. Nulla vel nisl finibus odio porttitor sagittis ac ut sem. Aenean orci enim, fringilla eu porta eget, egestas vel libero. Aenean ac efficitur nunc, id finibus nibh. Suspendisse potenti. Quisque vel vestibulum ante. Morbi mi nulla, gravida ac malesuada at, hendrerit nec nibh.
|
|
||||||
|
|
||||||
Fusce sodales convallis velit, ac tempor sem auctor sed.Aenean commodo sodales lorem eu mollis.Suspendisse lectus diam, bibendum quis maximus id, euismod placerat velit.Vestibulum hendrerit justo vel ultricies molestie.Donec rhoncus, ante at facilisis fermentum, diam diam hendrerit nunc, et dapibus lacus leo in massa.Duis iaculis sem sed molestie posuere.Morbi a erat hendrerit, volutpat libero non, elementum dui.
|
|
||||||
|
|
||||||
Cras imperdiet velit cursus, fringilla tellus eu, lacinia neque.Sed id est suscipit quam gravida vestibulum ut sed tortor.Aliquam erat volutpat.Praesent non orci ac quam consequat tempor.Nulla facilisi.Proin at vulputate lectus.Nunc at tellus at diam faucibus eleifend et et diam.Duis pellentesque lobortis lectus id egestas.Sed quis lacinia sapien.Quisque porta tincidunt pulvinar.Aliquam hendrerit hendrerit quam, sed pulvinar turpis dictum nec.
|
|
||||||
|
|
||||||
Donec bibendum, orci nec tempus fermentum, diam tellus pretium elit, vel porttitor ligula lectus a augue.Aliquam tristique, mi eu mollis sodales, enim lorem hendrerit est, id semper dui tellus id felis.Duis finibus lacus nunc, vitae tincidunt metus sagittis at.Curabitur euismod neque sed malesuada consectetur.Aliquam eget efficitur urna.Sed neque sem, interdum in turpis vitae, efficitur aliquam neque.Integer consectetur consequat diam, sed suscipit arcu maximus ac.Nunc imperdiet leo condimentum tellus luctus porta.Aenean et lorem sit amet eros rutrum fermentum.
|
|
||||||
|
|
||||||
Nam placerat leo sed nulla imperdiet dapibus.Etiam vitae tortor efficitur, interdum ipsum non, tincidunt ante.Quisque et placerat nisi, eu bibendum neque.Nulla facilisi.Pellentesque accumsan lacus arcu, vitae iaculis elit sollicitudin quis.Sed et iaculis neque.In quis nunc laoreet turpis fermentum sodales.Etiam eget sodales lorem.Nunc id risus ac purus mollis auctor.Integer imperdiet placerat massa eu efficitur.` });
|
|
||||||
</script>
|
|
||||||
@@ -1,35 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="status === 'pending'" class="flex">
|
<div class="flex flex-1 justify-start items-start" v-if="overview">
|
||||||
<Head>
|
<Head>
|
||||||
<Title>d[any] - Chargement</Title>
|
<Title>d[any] - {{ overview.title }}</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<Loading />
|
<Markdown v-if="overview.type === 'markdown'" :path="path" />
|
||||||
</div>
|
<Canvas v-else-if="overview.type === 'canvas'" :path="path" />
|
||||||
<div class="flex flex-1 justify-start items-start" v-else-if="page">
|
<ProseH2 v-else class="flex-1 text-center">Impossible d'afficher le contenu demandé</ProseH2>
|
||||||
<Head>
|
|
||||||
<Title>d[any] - {{ page.title }}</Title>
|
|
||||||
</Head>
|
|
||||||
<template v-if="page.type === 'markdown'">
|
|
||||||
<div class="flex flex-1 justify-start items-start flex-col xl:px-24 md:px-8 px-4 py-6">
|
|
||||||
<div class="flex flex-1 flex-row justify-between items-center">
|
|
||||||
<ProseH1>{{ page.title }}</ProseH1>
|
|
||||||
<NuxtLink :href="{ name: 'explore-edit-path', params: { path: path } }"><Button v-if="isOwner">Modifier</Button></NuxtLink>
|
|
||||||
</div>
|
|
||||||
<Markdown :content="page.content" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="page.type === 'canvas'">
|
|
||||||
<Canvas :canvas="JSON.parse(page.content)" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<ProseH2 class="flex-1 text-center">Impossible d'afficher le contenu demandé</ProseH2>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="status === 'error'">
|
|
||||||
<Head>
|
|
||||||
<Title>d[any] - Erreur</Title>
|
|
||||||
</Head>
|
|
||||||
<span>{{ error?.message }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<Head>
|
<Head>
|
||||||
@@ -42,8 +18,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const route = useRouter().currentRoute;
|
const route = useRouter().currentRoute;
|
||||||
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
|
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
|
||||||
const { loggedIn, user } = useUserSession();
|
|
||||||
|
|
||||||
const { data: page, status, error } = await useFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [route, path], });
|
const { content } = useContent();
|
||||||
const isOwner = computed(() => user.value?.id === page.value?.owner);
|
const overview = computed(() => content.value.find(e => e.path === path.value));
|
||||||
</script>
|
</script>
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="page" class="xl:p-12 lg:p-8 py-4 flex flex-1 flex-col items-start justify-start max-h-full">
|
|
||||||
<Head>
|
|
||||||
<Title>Modification de {{ page.title }}</Title>
|
|
||||||
</Head>
|
|
||||||
<div class="flex flex-col xl:flex-row xl:justify-between justify-center items-center w-full px-4 pb-4 border-b border-light-35 dark:border-dark-35 bg-light-0 dark:bg-dark-0">
|
|
||||||
<input type="text" v-model="page.title" placeholder="Titre" class="flex-1 mx-4 h-16 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 px-3 py-1 text-5xl font-thin bg-transparent" />
|
|
||||||
<div class="flex gap-4 self-end xl:self-auto">
|
|
||||||
<Tooltip message="Consultable uniquement par le propriétaire" side="bottom"><Switch label="Privé" v-model="page.private" /></Tooltip>
|
|
||||||
<Tooltip message="Afficher dans le menu de navigation" side="bottom"><Switch label="Navigable" v-model="page.navigable" /></Tooltip>
|
|
||||||
<Button @click="() => save()" :loading="saveStatus === 'pending'" class="border-light-blue dark:border-dark-blue hover:border-light-blue dark:hover:border-dark-blue focus:shadow-light-blue dark:focus:shadow-dark-blue">Enregistrer</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="my-4 flex-1 w-full max-h-full flex">
|
|
||||||
<template v-if="page.type === 'markdown'">
|
|
||||||
<SplitterGroup direction="horizontal" class="flex-1 w-full flex">
|
|
||||||
<SplitterPanel asChild>
|
|
||||||
<textarea v-model="page.content" class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto"></textarea>
|
|
||||||
</SplitterPanel>
|
|
||||||
<SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" />
|
|
||||||
<SplitterPanel asChild>
|
|
||||||
<div class="flex-1 max-h-full !overflow-y-auto px-8"><Markdown :content="debounced" :proses="{ 'a': FakeA }" /></div>
|
|
||||||
</SplitterPanel>
|
|
||||||
</SplitterGroup>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="page.type === 'canvas'">
|
|
||||||
<span class="flex-1 items-center"><ProseH1>Editeur de graphe en cours de développement</ProseH1></span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="page.type === 'file'">
|
|
||||||
<span>Modifier le contenu :</span><input type="file" @change="(e) => console.log(e)" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="status === 'pending'" class="flex">
|
|
||||||
<Head>
|
|
||||||
<Title>Chargement</Title>
|
|
||||||
</Head>
|
|
||||||
<Loading />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="status === 'error'">{{ error?.message }}</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import FakeA from '~/components/prose/FakeA.vue';
|
|
||||||
|
|
||||||
const route = useRouter().currentRoute;
|
|
||||||
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
|
|
||||||
const { user, loggedIn } = useUserSession();
|
|
||||||
|
|
||||||
const toaster = useToast();
|
|
||||||
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
|
|
||||||
|
|
||||||
const { data: page, status, error } = await useLazyFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [ route, path ]});
|
|
||||||
const content = computed(() => page.value?.content);
|
|
||||||
const debounced = useDebounce(content, 250);
|
|
||||||
|
|
||||||
if(!loggedIn || (page.value && page.value.owner !== user.value?.id))
|
|
||||||
{
|
|
||||||
useRouter().replace({ name: 'explore-path', params: { path: path.value } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(): Promise<void>
|
|
||||||
{
|
|
||||||
saveStatus.value = 'pending';
|
|
||||||
try {
|
|
||||||
await $fetch(`/api/file`, {
|
|
||||||
method: 'post',
|
|
||||||
body: page.value,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
saveStatus.value = 'success';
|
|
||||||
|
|
||||||
toaster.clear('error');
|
|
||||||
toaster.add({
|
|
||||||
type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000
|
|
||||||
});
|
|
||||||
|
|
||||||
useRouter().push({ name: 'explore-path', params: { path: path.value } });
|
|
||||||
} catch(e: any) {
|
|
||||||
toaster.add({
|
|
||||||
type: 'error', content: e.message, timer: true, duration: 10000
|
|
||||||
})
|
|
||||||
saveStatus.value = 'error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
578
pages/explore/edit/index.vue
Normal file
578
pages/explore/edit/index.vue
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</CollapsibleRoot>
|
||||||
|
</ClientOnly>
|
||||||
|
</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';
|
||||||
|
|
||||||
|
export type TreeItemEditable = TreeItem &
|
||||||
|
{
|
||||||
|
parent: string;
|
||||||
|
name: string;
|
||||||
|
customPath: boolean;
|
||||||
|
children?: TreeItemEditable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
rights: ['admin', 'editor'],
|
||||||
|
layout: 'null',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { user } = useUserSession();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
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>();
|
||||||
|
|
||||||
|
const preferences = useCookie<Preferences>('preferences', { default: () => ({ markdown: { editing: 'split' }, canvas: { snap: true, size: 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)}`);
|
||||||
|
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
router.replace({ hash: '' });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const content = computed(() => selected.value?.content ?? '');
|
||||||
|
const debounced = useDebounce(content, 250, { maxWait: 500 });
|
||||||
|
|
||||||
|
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_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)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const news = [...tree.search(navigation.value, '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];
|
||||||
|
}
|
||||||
|
else if(selected.value?.children)
|
||||||
|
{
|
||||||
|
item.parent = getPath(selected.value);
|
||||||
|
navigation.value = tree.insertChild(navigation.value, item.parent, item);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
navigation.value = tree.insertAfter(navigation.value, getPath(selected.value), item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateTree(instruction: Instruction, itemId: string, targetId: string) : TreeItemEditable[] | undefined {
|
||||||
|
if(!navigation.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const item = tree.find(navigation.value, itemId);
|
||||||
|
const target = tree.find(navigation.value, targetId);
|
||||||
|
|
||||||
|
if(!item)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (instruction.type === 'reparent') {
|
||||||
|
const path = tree.getPathToItem({
|
||||||
|
current: navigation.value,
|
||||||
|
targetId: targetId,
|
||||||
|
});
|
||||||
|
if (!path) {
|
||||||
|
console.error(`missing ${path}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredId = path[instruction.desiredLevel];
|
||||||
|
let result = tree.remove(navigation.value, 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;
|
||||||
|
|
||||||
|
if (instruction.type === 'reorder-above') {
|
||||||
|
let result = tree.remove(navigation.value, itemId);
|
||||||
|
result = tree.insertBefore(result, targetId, item);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instruction.type === 'reorder-below') {
|
||||||
|
let result = tree.remove(navigation.value, itemId);
|
||||||
|
result = tree.insertAfter(result, targetId, item);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instruction.type === 'make-child') {
|
||||||
|
if(!target || target.type !== 'folder')
|
||||||
|
return;
|
||||||
|
|
||||||
|
let result = tree.remove(navigation.value, itemId);
|
||||||
|
result = tree.insertChild(result, targetId, item);
|
||||||
|
rebuildPath([item], targetId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return navigation.value;
|
||||||
|
}
|
||||||
|
function transform(items: TreeItem[] | undefined): TreeItemEditable[] | undefined
|
||||||
|
{
|
||||||
|
return items?.map(e => ({
|
||||||
|
...e,
|
||||||
|
parent: e.path.substring(0, e.path.lastIndexOf('/')),
|
||||||
|
name: e.path.substring(e.path.lastIndexOf('/') + 1),
|
||||||
|
customPath: e.path.substring(e.path.lastIndexOf('/') + 1) !== parsePath(e.title),
|
||||||
|
children: transform(e.children)
|
||||||
|
})) as TreeItemEditable[] | undefined;
|
||||||
|
}
|
||||||
|
function flatten(items: TreeItemEditable[] | undefined): TreeItemEditable[]
|
||||||
|
{
|
||||||
|
return items?.flatMap(e => [e, ...flatten(e.children)]) ?? [];
|
||||||
|
}
|
||||||
|
function drop(instruction: Instruction, itemId: string, targetId: string)
|
||||||
|
{
|
||||||
|
navigation.value = updateTree(instruction, itemId, targetId) ?? navigation.value ?? [];
|
||||||
|
}
|
||||||
|
function rebuildPath(tree: TreeItemEditable[] | null | undefined, parentPath: string)
|
||||||
|
{
|
||||||
|
if(!tree)
|
||||||
|
return;
|
||||||
|
|
||||||
|
tree.forEach(e => {
|
||||||
|
e.parent = parentPath;
|
||||||
|
rebuildPath(e.children, getPath(e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function save(redirect: boolean): Promise<void>
|
||||||
|
{
|
||||||
|
//@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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
const split = router.currentRoute.value.hash.substring(1).split('/');
|
||||||
|
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));
|
||||||
|
else
|
||||||
|
selected.value = undefined;
|
||||||
|
}, { immediate: true });*/
|
||||||
|
</script>
|
||||||
@@ -1,21 +1,6 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const open = ref(false), username = ref(""), price = ref(750), disabled = ref(false), loading = ref(false);
|
|
||||||
|
|
||||||
watch(loading, (value) => {
|
|
||||||
if(value)
|
|
||||||
{
|
|
||||||
setTimeout(() => { open.value = true; loading.value = false }, 1500);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head>
|
<Head>
|
||||||
<Title>d[any] - Accueil</Title>
|
<Title>d[any] - Accueil</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<div class="h-full w-full flex flex-1 flex-col justify-center items-center">
|
|
||||||
<Avatar src="/logo.dark.svg" class="dark:block hidden w-48 h-48" />
|
|
||||||
<Avatar src="/logo.light.svg" class="block dark:hidden w-48 h-48" />
|
|
||||||
<h1 class="text-5xl font-thin font-mono">Bienvenue</h1>
|
<h1 class="text-5xl font-thin font-mono">Bienvenue</h1>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Mentions légales</Title>
|
||||||
|
</Head>
|
||||||
<div class="flex flex-col max-w-[1200px] p-16">
|
<div class="flex flex-col max-w-[1200px] p-16">
|
||||||
<ProseH3>Mentions Légales</ProseH3>
|
<ProseH3>Mentions Légales</ProseH3>
|
||||||
<ProseH4>Collecte et Traitement des Données Personnelles</ProseH4>
|
<ProseH4>Collecte et Traitement des Données Personnelles</ProseH4>
|
||||||
|
|||||||
53
pages/roadmap.vue
Normal file
53
pages/roadmap.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Roadmap</Title>
|
||||||
|
</Head>
|
||||||
|
<div class="flex flex-col justify-start p-6">
|
||||||
|
<ProseH2>Roadmap</ProseH2>
|
||||||
|
<div class="grid grid-cols-4 gap-x-2 gap-y-4">
|
||||||
|
<div v-if="loggedIn && user && hasPermissions(user.permissions, ['admin'])" class="flex flex-col gap-2 justify-start">
|
||||||
|
<ProseH3>Administration</ProseH3>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Dashboard de statistiques</span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Editeur de permissions</span><ProseTag>prioritaire</ProseTag></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Synchro project <-> GIT</span><ProseTag>prioritaire</ProseTag></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Versionning automatisé, releases et newsletter</span></Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 justify-start">
|
||||||
|
<ProseH3>Editeur</ProseH3>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition de page</span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition riche de page</span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Edition live de page</span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Raccourcis d'edition</span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Affichage alternatif par page</span></Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 justify-start">
|
||||||
|
<ProseH3>Projet</ProseH3>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition du projet</span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Déplacement des fichiers</span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Configuration de droit du projet</span><ProseTag>prioritaire</ProseTag></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Theme par projet</span></Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 justify-start">
|
||||||
|
<ProseH3>Nouvelles features</ProseH3>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Historique des modifs</span><ProseTag>prioritaire</ProseTag></Label><!-- Objet release: key hash, timestamp, version, name, description?. Objet edit: key hash, key property, value, timestamp -->
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Commentaire par page</span></Label><!-- Object comment: key path, key comment_id, position, content, owner, following? -->
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Type de fichier: Timeline</span></Label><!-- Propriétés: array of (from, (to || ponctual), ((title, content) || dedicated page)) -->
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Type de fichier: Whiteboard</span></Label><!-- Tableau de données SVG -->
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 justify-start">
|
||||||
|
<ProseH3>Utilisateur</ProseH3>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Validation du compte par mail<ProseTag>prioritaire</ProseTag></span></Label>
|
||||||
|
<!-- <Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Modification de profil</span></Label> -->
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Image de profil</span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Préférence d'email</span></Label><!-- New features, newsletter et surveys -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
import { hasPermissions } from '~/shared/auth.util';
|
||||||
|
|
||||||
|
const { loggedIn, user } = useUserSession();
|
||||||
|
</script>
|
||||||
18
pages/user/(automatic)/mailvalidated.vue
Normal file
18
pages/user/(automatic)/mailvalidated.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Validation de votre adresse mail</Title>
|
||||||
|
</Head>
|
||||||
|
<div class="flex flex-col justify-center items-center">
|
||||||
|
<ProseH2>Votre compte a été validé ! 🎉</ProseH2>
|
||||||
|
<div class="flex flex-row gap-8">
|
||||||
|
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'user-login', replace: true }">Se connecter</NuxtLink></Button>
|
||||||
|
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'index', replace: true }">Retourner à l'accueil</NuxtLink></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'login',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
47
pages/user/(automatic)/reset-password.vue
Normal file
47
pages/user/(automatic)/reset-password.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Reinitialisation de mon mot de passe</Title>
|
||||||
|
</Head>
|
||||||
|
<div class="flex flex-1 flex-col justify-center items-center">
|
||||||
|
<div class="flex gap-8 items-center">
|
||||||
|
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||||
|
<ProseH4>Reinitialisation de mon mot de passe</ProseH4>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">
|
||||||
|
<TextInput type="text" label="Utilisateur ou email" autocomplete="username" v-model="email"/>
|
||||||
|
<Button class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Envoyer un email</Button>
|
||||||
|
</form>
|
||||||
|
<div v-if="status === 'success'" class="border border-light-green dark:border-dark-green bg-light-greenBack dark:bg-dark-greenBack text-wrap mt-4 py-2 px-4 max-w-96">
|
||||||
|
Un mail vous a été envoyé si un compte existe pour cet identifiant.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'login',
|
||||||
|
usersGoesTo: '/user/profile',
|
||||||
|
});
|
||||||
|
|
||||||
|
const toaster = useToast();
|
||||||
|
|
||||||
|
const email = ref(''), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
async function submit()
|
||||||
|
{
|
||||||
|
status.value = 'pending';
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/auth/request-reset`, {
|
||||||
|
body: { profile: email.value },
|
||||||
|
method: 'post',
|
||||||
|
});
|
||||||
|
status.value = 'success';
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
status.value = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
87
pages/user/(automatic)/resetting-password.vue
Normal file
87
pages/user/(automatic)/resetting-password.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Reinitialisation de mon mot de passe</Title>
|
||||||
|
</Head>
|
||||||
|
<div class="flex flex-1 flex-col justify-center items-center">
|
||||||
|
<div class="flex gap-8 items-center">
|
||||||
|
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||||
|
<ProseH4>Reinitialisation de mon mot de passe</ProseH4>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
|
||||||
|
<TextInput type="password" label="Nouveau mot de passe" autocomplete="newPassword" v-model="newPasswd" :class="{ '!border-light-red !dark:border-dark-red': error }"/>
|
||||||
|
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||||
|
<span class="col-span-2">Prérequis de sécurité</span>
|
||||||
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
|
||||||
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLower}"><Icon v-show="!checkedLower" icon="radix-icons:cross-2" />Une minuscule</span>
|
||||||
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedUpper}"><Icon v-show="!checkedUpper" icon="radix-icons:cross-2" />Une majuscule</span>
|
||||||
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedDigit}"><Icon v-show="!checkedDigit" icon="radix-icons:cross-2" />Un chiffre</span>
|
||||||
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
|
||||||
|
</div>
|
||||||
|
<TextInput type="password" label="Repeter le nouveau mot de passe" autocomplete="newPassword" v-model="repeatPasswd" :class="{ 'border-light-red dark:border-dark-red': manualError }"/>
|
||||||
|
<Button class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Reinitialiser</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'login',
|
||||||
|
usersGoesTo: '/user/login',
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = useRouter().currentRoute.value.query;
|
||||||
|
|
||||||
|
const toaster = useToast();
|
||||||
|
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), manualError = ref(false);
|
||||||
|
const oldPasswd = ref(''), newPasswd = ref(''), repeatPasswd = ref('');
|
||||||
|
|
||||||
|
const checkedLength = computed(() => newPasswd.value.length >= 8 && newPasswd.value.length <= 128);
|
||||||
|
const checkedLower = computed(() => newPasswd.value.toUpperCase() !== newPasswd.value);
|
||||||
|
const checkedUpper = computed(() => newPasswd.value.toLowerCase() !== newPasswd.value);
|
||||||
|
const checkedDigit = computed(() => /[0-9]/.test(newPasswd.value));
|
||||||
|
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => newPasswd.value.includes(e)));
|
||||||
|
|
||||||
|
const equalsPasswd = computed(() => newPasswd.value && repeatPasswd.value && newPasswd.value === repeatPasswd.value);
|
||||||
|
|
||||||
|
const error = computed(() => !checkedLength.value || !checkedLower.value || !checkedUpper.value || !checkedDigit.value || !checkedSymbol.value);
|
||||||
|
|
||||||
|
async function submit()
|
||||||
|
{
|
||||||
|
if(!equalsPasswd.value)
|
||||||
|
{
|
||||||
|
manualError.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
manualError.value = false;
|
||||||
|
status.value = 'pending';
|
||||||
|
try {
|
||||||
|
const result = await $fetch(`/api/users/${query.i}/reset-password`, {
|
||||||
|
method: 'post',
|
||||||
|
body: {
|
||||||
|
password: newPasswd.value,
|
||||||
|
},
|
||||||
|
query: query,
|
||||||
|
});
|
||||||
|
|
||||||
|
if(result && result.success)
|
||||||
|
{
|
||||||
|
status.value = 'success';
|
||||||
|
|
||||||
|
toaster.add({ content: 'Votre mot de passe a été modifié avec succès.', duration: 10000, timer: true, type: 'success' });
|
||||||
|
useRouter().push({ name: 'user-login' });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw result.error ?? new Error('Erreur inconnue.');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
status.value = 'error';
|
||||||
|
|
||||||
|
const err = e as any;
|
||||||
|
toaster.add({ content: err?.data?.message ?? err?.message ?? 'Erreur inconnue', duration: 10000, timer: true, type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
88
pages/user/changing-password.vue
Normal file
88
pages/user/changing-password.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Modification de mon mot de passe</Title>
|
||||||
|
</Head>
|
||||||
|
<div class="flex flex-1 flex-col justify-center items-center">
|
||||||
|
<div class="flex gap-8 items-center">
|
||||||
|
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||||
|
<ProseH4>Modification de mon mot de passe</ProseH4>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
|
||||||
|
<TextInput type="password" label="Ancien mot de passe" name="old-password" autocomplete="current-password" v-model="oldPasswd"/>
|
||||||
|
<TextInput type="password" label="Nouveau mot de passe" name="new-password" autocomplete="new-password" v-model="newPasswd" :class="{ 'border-light-red dark:border-dark-red': error }"/>
|
||||||
|
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||||
|
<span class="col-span-2">Prérequis de sécurité</span>
|
||||||
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
|
||||||
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLower}"><Icon v-show="!checkedLower" icon="radix-icons:cross-2" />Une minuscule</span>
|
||||||
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedUpper}"><Icon v-show="!checkedUpper" icon="radix-icons:cross-2" />Une majuscule</span>
|
||||||
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedDigit}"><Icon v-show="!checkedDigit" icon="radix-icons:cross-2" />Un chiffre</span>
|
||||||
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
|
||||||
|
</div>
|
||||||
|
<TextInput type="password" label="Repeter le nouveau mot de passe" autocomplete="new-password" v-model="repeatPasswd" :class="{ 'border-light-red dark:border-dark-red': manualError }"/>
|
||||||
|
<Button type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Mettre à jour mon mot de passe</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'login',
|
||||||
|
guestsGoesTo: '/user/login',
|
||||||
|
});
|
||||||
|
|
||||||
|
const toaster = useToast();
|
||||||
|
const { user } = useUserSession();
|
||||||
|
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), manualError = ref(false);
|
||||||
|
const oldPasswd = ref(''), newPasswd = ref(''), repeatPasswd = ref('');
|
||||||
|
|
||||||
|
const checkedLength = computed(() => newPasswd.value.length >= 8 && newPasswd.value.length <= 128);
|
||||||
|
const checkedLower = computed(() => newPasswd.value.toUpperCase() !== newPasswd.value);
|
||||||
|
const checkedUpper = computed(() => newPasswd.value.toLowerCase() !== newPasswd.value);
|
||||||
|
const checkedDigit = computed(() => /[0-9]/.test(newPasswd.value));
|
||||||
|
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => newPasswd.value.includes(e)));
|
||||||
|
|
||||||
|
const equalsPasswd = computed(() => newPasswd.value && repeatPasswd.value && newPasswd.value === repeatPasswd.value);
|
||||||
|
|
||||||
|
const error = computed(() => !checkedLength.value || !checkedLower.value || !checkedUpper.value || !checkedDigit.value || !checkedSymbol.value);
|
||||||
|
|
||||||
|
async function submit()
|
||||||
|
{
|
||||||
|
if(!equalsPasswd.value)
|
||||||
|
{
|
||||||
|
manualError.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
manualError.value = false;
|
||||||
|
status.value = 'pending';
|
||||||
|
try {
|
||||||
|
const result = await $fetch(`/api/users/${user.value?.id}/change-password`, {
|
||||||
|
method: 'post',
|
||||||
|
body: {
|
||||||
|
oldPassword: oldPasswd.value,
|
||||||
|
newPassword: newPasswd.value,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(result && result.success)
|
||||||
|
{
|
||||||
|
status.value = 'success';
|
||||||
|
|
||||||
|
toaster.add({ content: 'Votre mot de passe a été modifié avec succès.', duration: 10000, timer: true, type: 'success' });
|
||||||
|
useRouter().push({ name: 'user-profile' });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
status.value = 'error';
|
||||||
|
|
||||||
|
toaster.add({ content: result.error ?? 'Erreur inconnue', duration: 10000, timer: true, type: 'error' });
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
status.value = 'error';
|
||||||
|
|
||||||
|
toaster.add({ content: (e as Error).message ?? e, duration: 10000, timer: true, type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Head>
|
<Head>
|
||||||
<Title>Connexion</Title>
|
<Title>d[any] - Connexion</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<div class="flex flex-1 flex-col justify-center items-center">
|
<div class="flex flex-1 flex-col justify-center items-center">
|
||||||
<div class="flex gap-8 items-center">
|
<div class="flex gap-8 items-center">
|
||||||
@@ -8,9 +8,10 @@
|
|||||||
<ProseH4>Connexion</ProseH4>
|
<ProseH4>Connexion</ProseH4>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">
|
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">
|
||||||
<TextInput type="text" label="Utilisateur ou email" autocomplete="username" v-model="state.usernameOrEmail"/>
|
<TextInput type="text" label="Utilisateur ou email" name="username" autocomplete="username email" v-model="state.usernameOrEmail"/>
|
||||||
<TextInput type="password" label="Mot de passe" autocomplete="current-password" v-model="state.password"/>
|
<TextInput type="password" label="Mot de passe" name="password" autocomplete="current-password" v-model="state.password"/>
|
||||||
<Button class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Se connecter</Button>
|
<Button type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Se connecter</Button>
|
||||||
|
<NuxtLink class="mt-4 text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-reset-password' }">Mot de passe oublié ?</NuxtLink>
|
||||||
<NuxtLink class="mt-4 text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-register' }">Pas de compte ?</NuxtLink>
|
<NuxtLink class="mt-4 text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-register' }">Pas de compte ?</NuxtLink>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { hasPermissions } from "#shared/auth.util";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
guestsGoesTo: '/user/login',
|
guestsGoesTo: '/user/login',
|
||||||
})
|
})
|
||||||
let { user, clear } = useUserSession();
|
const { user, clear } = useUserSession();
|
||||||
|
const toaster = useToast();
|
||||||
|
const loading = ref<boolean>(false);
|
||||||
|
|
||||||
|
async function revalidateUser()
|
||||||
|
{
|
||||||
|
loading.value = true;
|
||||||
|
await $fetch(`/api/users/${user.value?.id}/revalidate`, {
|
||||||
|
method: 'post'
|
||||||
|
});
|
||||||
|
loading.value = false;
|
||||||
|
toaster.add({ closeable: false, duration: 10000, timer: true, content: 'Un mail vous a été envoyé.', type: 'info' });
|
||||||
|
}
|
||||||
async function deleteUser()
|
async function deleteUser()
|
||||||
{
|
{
|
||||||
|
loading.value = true;
|
||||||
await $fetch(`/api/users/${user.value?.id}`, {
|
await $fetch(`/api/users/${user.value?.id}`, {
|
||||||
method: 'delete'
|
method: 'delete'
|
||||||
});
|
});
|
||||||
|
loading.value = false;
|
||||||
clear();
|
clear();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -16,7 +31,7 @@ async function deleteUser()
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
<Head>
|
<Head>
|
||||||
<Title>Mon profil</Title>
|
<Title>d[any] - Mon profil</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<div class="grid lg:grid-cols-4 grid-col-2 w-full items-start py-8 gap-6 content-start" v-if="user">
|
<div class="grid lg:grid-cols-4 grid-col-2 w-full items-start py-8 gap-6 content-start" v-if="user">
|
||||||
<div class="flex flex-col gap-4 col-span-4 lg:col-span-3 border border-light-35 dark:border-dark-35 p-4">
|
<div class="flex flex-col gap-4 col-span-4 lg:col-span-3 border border-light-35 dark:border-dark-35 p-4">
|
||||||
@@ -34,14 +49,14 @@ async function deleteUser()
|
|||||||
<template v-slot:content><span>Tant que votre adresse mail n'as pas été validée, vous n'avez que
|
<template v-slot:content><span>Tant que votre adresse mail n'as pas été validée, vous n'avez que
|
||||||
des droits de lecture.</span></template>
|
des droits de lecture.</span></template>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
<Tooltip message="En cours de développement"><Button class="ms-4" disabled>Renvoyez un mail</Button></Tooltip>
|
<Button class="ms-4" @click="revalidateUser" :loading="loading">Renvoyez un mail</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col self-center flex-1 gap-4">
|
<div class="flex flex-col self-center flex-1 gap-4">
|
||||||
<Button @click="async () => await clear()">Se deconnecter</Button>
|
<Button @click="clear">Se deconnecter</Button>
|
||||||
<Button disabled><Tooltip message="En cours de développement">Modifier mon profil</Tooltip></Button>
|
<NuxtLink :to="{ name: 'user-changing-password' }" class="flex flex-1"><Button>Modifier mon mot de passe</Button></NuxtLink>
|
||||||
<AlertDialogRoot>
|
<AlertDialogRoot>
|
||||||
<AlertDialogTrigger asChild><Button
|
<AlertDialogTrigger asChild><Button :loading="loading"
|
||||||
class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer
|
class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer
|
||||||
mon compte</Button></AlertDialogTrigger>
|
mon compte</Button></AlertDialogTrigger>
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
@@ -64,19 +79,5 @@ async function deleteUser()
|
|||||||
</AlertDialogRoot>
|
</AlertDialogRoot>
|
||||||
<NuxtLink v-if="hasPermissions(user.permissions, ['admin'])" :href="{ name: 'admin' }" class="flex" no-prefetch><Button class="flex-1">Administration</Button></NuxtLink>
|
<NuxtLink v-if="hasPermissions(user.permissions, ['admin'])" :href="{ name: 'admin' }" class="flex" no-prefetch><Button class="flex-1">Administration</Button></NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex" v-if="user.permissions">
|
|
||||||
<ProseTable class="!m-0">
|
|
||||||
<ProseThead>
|
|
||||||
<ProseTr>
|
|
||||||
<ProseTh>Permission</ProseTh>
|
|
||||||
</ProseTr>
|
|
||||||
</ProseThead>
|
|
||||||
<ProseTbody>
|
|
||||||
<ProseTr v-for="permission in user.permissions">
|
|
||||||
<ProseTd>{{ permission }}</ProseTd>
|
|
||||||
</ProseTr>
|
|
||||||
</ProseTbody>
|
|
||||||
</ProseTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Head>
|
<Head>
|
||||||
<Title>Inscription</Title>
|
<Title>d[any] - Inscription</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<div class="flex flex-1 flex-col justify-center items-center">
|
<div class="flex flex-1 flex-col justify-center items-center">
|
||||||
<div class="flex gap-8 items-center">
|
<div class="flex gap-8 items-center">
|
||||||
@@ -8,25 +8,19 @@
|
|||||||
<ProseH4>Inscription</ProseH4>
|
<ProseH4>Inscription</ProseH4>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0">
|
<form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0">
|
||||||
<TextInput type="text" label="Nom d'utilisateur" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
|
<TextInput type="text" label="Nom d'utilisateur" name="username" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
|
||||||
<TextInput type="email" label="Email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/>
|
<TextInput type="email" label="Email" name="email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/>
|
||||||
<TextInput type="password" label="Mot de passe" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/>
|
<TextInput type="password" label="Mot de passe" name="password" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/>
|
||||||
<div class="flex flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||||
<span class="">Votre mot de passe doit respecter les critères de sécurité suivants
|
<span class="col-span-2">Prérequis de sécurité</span>
|
||||||
:</span>
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
|
||||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedLength}">Entre 8 et 128
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLower}"><Icon v-show="!checkedLower" icon="radix-icons:cross-2" />Une minuscule</span>
|
||||||
caractères</span>
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedUpper}"><Icon v-show="!checkedUpper" icon="radix-icons:cross-2" />Une majuscule</span>
|
||||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedLowerUpper}">Au moins
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedDigit}"><Icon v-show="!checkedDigit" icon="radix-icons:cross-2" />Un chiffre</span>
|
||||||
une minuscule et une majuscule</span>
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
|
||||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedDigit}">Au moins un
|
|
||||||
chiffre</span>
|
|
||||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}">Au moins un
|
|
||||||
caractère spécial parmi la liste suivante:
|
|
||||||
<pre class="text-wrap">! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ ] ^ _ ` { | } ~</pre>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
|
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
|
||||||
<Button class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
|
<Button type="submit" class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
|
||||||
<span class="mt-4 order-10 flex justify-center items-center gap-4 col-span-2 md:col-span-1 m-auto">Vous avez déjà un compte ?<NuxtLink class="text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-login' }">Se connecter</NuxtLink></span>
|
<span class="mt-4 order-10 flex justify-center items-center gap-4 col-span-2 md:col-span-1 m-auto">Vous avez déjà un compte ?<NuxtLink class="text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-login' }">Se connecter</NuxtLink></span>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +46,8 @@ const { add: addToast, clear: clearToasts } = useToast();
|
|||||||
const confirmPassword = ref("");
|
const confirmPassword = ref("");
|
||||||
|
|
||||||
const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128);
|
const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128);
|
||||||
const checkedLowerUpper = computed(() => state.password.toLowerCase() !== state.password && state.password.toUpperCase() !== state.password);
|
const checkedLower = computed(() => state.password.toUpperCase() !== state.password);
|
||||||
|
const checkedUpper = computed(() => state.password.toLowerCase() !== state.password);
|
||||||
const checkedDigit = computed(() => /[0-9]/.test(state.password));
|
const checkedDigit = computed(() => /[0-9]/.test(state.password));
|
||||||
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));
|
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));
|
||||||
|
|
||||||
|
|||||||
7
plugins/autofocus.ts
Normal file
7
plugins/autofocus.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.vueApp.directive('autofocus', {
|
||||||
|
mounted(el, binding) {
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
BIN
public/logo.light.png
Normal file
BIN
public/logo.light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user