84 Commits

Author SHA1 Message Date
Clément Pons
735dfb6980 Fix mail sending 2025-04-22 09:28:48 +02:00
7beeed8a61 Fix zoom performance issues (for real this time) 2025-04-19 14:25:22 +02:00
403a65158a Fix zoom performance issues 2025-04-19 13:44:31 +02:00
fef7c8f57c Fix zoom on mobile 2025-04-19 13:42:18 +02:00
e5b53585aa Fix pull job and link rewrite 2025-04-18 20:10:21 +02:00
e7d0d69e55 Update config to add max age to session cookie (so it is no longer session-only and keep the user logged in) 2025-03-28 19:57:55 +01:00
f2d00097d6 Add grid snapping, grid preview, fix zoom slowdowns and canvas markdown editing being at the wrong size. 2025-03-04 15:14:12 +01:00
0b97e9a295 New HyperMD implementation with custom behaviour. 2025-02-25 15:55:30 +01:00
6abc467a43 Add edge dragging, autofocus on inputs and limit neighbor distance lookup during snap fetching 2025-02-13 19:41:19 +01:00
939b9cbd28 Add neighbor snapping. Add edge snapping. Change accent colors and logo colors, fix canvas history being transported when changing canvas. 2025-02-06 23:36:55 +01:00
e2c18ff406 Fix reading canvas moving only with middle click 2025-02-01 14:41:08 +01:00
154584e175 Copy readonly features from canvas editor to canvas reader. 2025-02-01 13:45:16 +01:00
af317cb0e3 Navbar rework, several CSS fixes, Markdown preferences 2025-02-01 10:58:52 +01:00
8fc1855ae6 Merge branch 'dev' of https://git.peaceultime.com/peaceultime/obsidian-visualiser into dev 2025-01-29 22:53:05 +01:00
f3c453b1b2 Renaming general.utils to general.util 2025-01-29 22:51:55 +01:00
62b2f3bbfb Fix autocomplete 2025-01-29 17:39:42 +01:00
0b1809c3f6 Add grid snapping, @TODO: Add settings popup with grid settings + render grid. 2025-01-28 17:55:47 +01:00
3f04bb3d0c Revert "Update packages. Add quadtree (still need update for ID handling instead of index)."
This reverts commit 685bd47fc4.
2025-01-28 10:27:34 +01:00
685bd47fc4 Update packages. Add quadtree (still need update for ID handling instead of index). 2025-01-23 14:05:18 +01:00
f32c51ca38 Remove Tweening (looked laggy). Add edge property editing. Improve Edge selection and visualisation. 2025-01-16 17:39:33 +01:00
348c991c54 Add edge editor, generalize selection and edition to both node and edge. Still trying to find a proper tween. 2025-01-14 17:57:57 +01:00
76db788192 Add Tweening to zoom, fix saving canvas. 2025-01-14 00:04:14 +01:00
4433cf0e00 Rework the structure to handle suppression (using ID instead of index). Add create history and removing. 2025-01-13 00:27:14 +01:00
9439dd2d95 Add node resizing 2025-01-13 00:00:17 +01:00
823f3d7730 Improve history handling, add color picking and node creation. 2025-01-12 23:27:34 +01:00
62950be032 Minimal history handler, handle node move. Auto parse JSON content for accurate typing. 2025-01-09 16:41:36 +01:00
b1a9eb859e Small fixes 2025-01-08 22:57:09 +01:00
83ac9b1f36 Merge branch 'dev' of https://git.peaceultime.com/peaceultime/obsidian-visualiser into dev 2025-01-08 21:41:35 +01:00
7403515f80 Add certificate and https to allow --host (and testing on mobile) 2025-01-08 21:41:34 +01:00
3839b003dc New event handling system for CanvasEditor in progress. 2025-01-08 17:39:34 +01:00
e7412f6768 Progressing on CanvasEditor 2025-01-07 17:49:53 +01:00
6f305397a8 Starting new file format "Map". Preparing editor for Map and Canvas editor with metadata folding UI. Fix comments filtering. 2025-01-06 17:46:31 +01:00
896af11fa7 Fix useContent not using cookies therefore skipping the auth step 2025-01-05 22:43:40 +01:00
9515132659 New useContent composable to store global navigation state. Fixes for Markdown and Canvas 2024-12-30 20:46:24 +01:00
031a51c2fe Change explorer pages structure to isolates them from there renderers allowing to fetch data outside of the renderers 2024-12-18 17:35:34 +01:00
7bdf6ccd13 Filter ProseA hover cards with the provided hash 2024-12-17 22:39:17 +01:00
cb2c19fada Merge branch 'dev' of https://git.peaceultime.com/peaceultime/obsidian-visualiser into dev 2024-12-17 20:37:00 +01:00
0abf0b11e6 Fix folder editing and add links updates on file rename 2024-12-17 20:36:51 +01:00
ec0afa9686 Password reset and new email validation ID stored in DB for more security 2024-12-17 17:51:12 +01:00
b24a083d2e Smaller, more detailled private todo list 2024-12-16 11:55:33 +01:00
ad61dc8897 Fix sitemap providing explorer folders 2024-12-11 14:34:55 +01:00
1e8afe90dd Fix new mailserver settings 2024-12-11 14:31:02 +01:00
8439d3444f Merge branch 'dev' 2024-12-11 13:44:19 +01:00
36909c5d66 Fix sitemap explore URL not being URI encoded 2024-12-11 13:43:26 +01:00
fea37e2f59 Merge branch 'dev' of https://git.peaceultime.com/Peaceultime/obsidian-visualiser into dev 2024-12-11 12:37:57 +00:00
a3d9e466a5 Changing website domain 2024-12-11 13:36:11 +01:00
9c69ff2903 Add ProseSmall as a private commentary (using %%) 2024-12-10 17:26:35 +01:00
3b919075ef Fix callout disabled and update dependencies 2024-12-05 13:50:59 +01:00
4150b69ba3 Fix BUN preset for Nitro 2024-12-05 13:19:09 +01:00
298f47a280 Add callout icons and fix some CSS 2024-12-05 13:04:01 +01:00
161f0d856a Add Callout as a separate Prose 2024-12-04 18:01:09 +01:00
51a5d501be Polish CSS for mobile editor. Add user logout from admin panel. 2024-12-03 14:22:02 +01:00
ecdfa947ac Planning and updating roadmap 2024-12-02 17:42:04 +01:00
fd951c294f Rework the project editor to include file edition and display the file sorter in the sidebar. 2024-12-02 15:31:16 +01:00
602b0af212 Rework file access and link archiving 2024-12-01 23:25:33 +01:00
f7094f7ce1 Rework file access and link archiving 2024-12-01 23:17:41 +01:00
429f1d4b38 Add page and user monitoring in admin. Add permission editing in administration. 2024-11-28 17:18:35 +01:00
5062d52667 Merge branch 'dev' of https://git.peaceultime.com/Peaceultime/obsidian-visualiser into dev 2024-11-28 09:53:05 +01:00
c4bf95e48b Change gitignore to remove the DB now that the deployement process can backup the DB 2024-11-28 09:53:02 +01:00
7fc7998a4b Change prefetch settings 2024-11-27 22:36:03 +01:00
fdaf765e2d Remove prefetch from every NuxtLink 2024-11-30 18:43:39 +01:00
e99a5f15b4 Add user and page statistics, add sitemap and robots.txt generation 2024-11-27 17:07:32 +01:00
5fb708051b Small fixes 2024-11-29 23:52:57 +01:00
9a69a92ef8 Build fixes 2024-11-26 16:49:07 +01:00
f22e63bd4d Small fixes 2024-11-26 16:38:58 +01:00
e83d8e802f Add revalidation email 2024-11-26 16:23:33 +01:00
3e463ea286 Switch pull and push to make more sense 2024-11-26 15:29:31 +01:00
4125cbb3a2 Send registration email, add mail validation page, stabilize mail generation 2024-11-26 15:22:57 +01:00
4df9297d47 Add mail template, mail HTML generation and a few UI fixes 2024-11-25 17:37:43 +01:00
d71e8b7910 Small UI improvements and fixes to project edition 2024-11-28 00:01:57 +01:00
20ab51a66c Visual edits and fixes, navigation sorting fix 2024-11-23 19:18:50 +01:00
2855d4ba2e Add deletion of files, add slots to tree and a global icon by type. Project update needs rework. 2024-11-20 22:51:51 +01:00
4f2fc31695 Add new files in project config 2024-11-20 17:36:33 +01:00
6e7243982b Small progress on new (file/folder) and remove in project settings 2024-11-19 23:30:43 +01:00
9c52494f8e Mail sending ready 2024-11-19 18:07:48 +01:00
d0de943df2 Starting to setup emails 2024-11-19 00:37:41 +01:00
1c239f161b Finished project configuration page with reorder 2024-11-18 17:21:52 +01:00
a9363e8c06 Progressing on the draggable tree for the project configuration 2024-11-18 00:10:23 +01:00
d708e9ceb6 Finish Dropdown menu and start project config page 2024-11-14 17:32:13 +01:00
0c17dbf7bc Merge branch 'dev' of https://git.peaceultime.com/Peaceultime/obsidian-visualiser into dev 2024-11-13 13:41:35 +01:00
ac17134b7e Add title to every pages + new pull/push jobs + DropdownMenu 2024-11-13 13:41:32 +01:00
adb37b255a Add order in file schema and main projecct edition 2024-11-13 00:25:18 +01:00
b54402fc19 Fix canvas control buttons being unusable on mobile 2024-11-11 00:00:34 +01:00
0882eb1dd0 Add collapsible properties to editor splitter for better mobile accessibility 2024-11-10 23:45:53 +01:00
140 changed files with 11304 additions and 1480 deletions

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ logs
.env
.env.*
!.env.example
bun.lockb
db.sqlite
db.sqlite-wal
db.sqlite-shm

View File

@@ -1,10 +1,11 @@
<template>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
<NuxtRouteAnnouncer/>
<NuxtLoadingIndicator />
<TooltipProvider>
<NuxtLayout>
<div class="xl:ps-12 xl:pe-12 ps-6 pe-4 flex flex-1 justify-center overflow-auto max-h-full relative">
<NuxtPage></NuxtPage>
<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 />
</div>
</NuxtLayout>
<Toaster v-model="list" />

2952
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

931
components/CanvasEditor.vue Normal file
View 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>

View File

@@ -1,85 +1,134 @@
<script lang="ts">
import type { Editor, EditorConfiguration, EditorChange } from 'codemirror';
import { fromTextArea } from 'hypermd';
import '#shared/hypermd.extend';
</script>
<script setup lang="ts">
import { dropCursor, crosshairCursor, keymap, EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldKeymap } from '@codemirror/language';
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands';
import { searchKeymap } from '@codemirror/search';
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint';
const editor = useTemplateRef('editor');
const view = ref<EditorView>();
const state = ref<EditorState>();
const { placeholder, autofocus = false, gutters = true, format = 'd-any' } = defineProps<{
placeholder?: string
autofocus?: boolean
gutters?: boolean
format?: 'hypermd' | 'd-any'
}>();
const model = defineModel<string>();
const editor = ref<Editor>();
const input = useTemplateRef('input');
onMounted(() => {
if(editor.value)
if(input.value)
{
state.value = EditorState.create({
doc: model.value,
extensions: [
history(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
crosshairCursor(),
EditorView.lineWrapping,
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap
])
]
});
view.value = new EditorView({
state: state.value,
parent: editor.value,
});
const e = editor.value = fromTextArea(input.value, {
mode: {
name: format,
hashtag: true,
toc: false,
math: false,
orgModeMarkup: false,
tokenTypeOverrides: {
hr: "line-HyperMD-hr line-background-HyperMD-hr-bg hr",
list1: "list-1",
list2: "list-2",
list3: "list-3",
code: "inline-code",
hashtag: "hashtag meta"
}
})
},
spellcheck: true,
autofocus: autofocus,
lineNumbers: false,
showCursorWhenSelecting: true,
indentUnit: 4,
autoCloseBrackets: true,
foldGutter: gutters,
theme: 'custom'
} as EditorConfiguration);
onBeforeUnmount(() => {
if (view.value) {
view.value?.destroy()
view.value = undefined
e.setValue(model.value ?? '');
e.on('change', (cm: Editor, change: EditorChange) => model.value = cm.getValue());
}
});
watchEffect(() => {
if (model.value === void 0) {
return;
}
const currentValue = view.value ? view.value.state.doc.toString() : "";
if (view.value && model.value !== currentValue) {
view.value.dispatch({
changes: { from: 0, to: currentValue.length, insert: model.value || "" }
});
const value = editor.value?.getValue();
if (editor.value && model.value !== value) {
editor.value.setValue(model.value ?? '');
editor.value.clearHistory();
}
});
defineExpose({ focus: () => editor.value?.focus() });
</script>
<template>
<div class="flex flex-1 justify-center items-start p-12">
<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 :class="{ 'cancel-gutters': !gutters }" class="flex flex-1 w-full justify-stretch items-stretch !font-sans !text-base">
<textarea ref="input" class="hidden"></textarea>
</div>
</template>
<style>
.cm-editor
.CodeMirror
{
@apply bg-transparent;
@apply flex-1 h-full;
@apply font-sans;
@apply text-light-100 dark:text-dark-100;
}
.cm-editor .cm-content
.cancel-gutters .CodeMirror-gutters
{
@apply caret-light-100;
@apply dark:caret-dark-100;
@apply hidden;
}
.CodeMirror-sizer
{
@apply !px-3;
}
.cancel-gutters .CodeMirror-sizer
{
@apply ms-2;
}
.CodeMirror-gutters
{
@apply bg-transparent;
@apply border-transparent;
}
.CodeMirror-gutter-wrapper
{
@apply absolute top-0 bottom-0;
@apply flex justify-center items-center;
}
.CodeMirror-foldmarker
{
@apply text-light-100;
@apply dark:text-dark-100;
@apply ps-3;
text-shadow: none;
}
.hmd-inactive-line .cm-formatting-header, .hmd-inactive-line .cm-formatting-link, .hmd-inactive-line .cm-link-has-alias, .hmd-inactive-line .cm-link-alias-pipe
{
@apply hidden;
}
.CodeMirror-line
{
@apply text-base;
}
.CodeMirror-cursor
{
@apply border-light-100 dark:border-dark-100;
}
.CodeMirror-selected
{
@apply bg-light-35 dark:bg-dark-35;
}
.HyperMD-list-line-1 {
@apply !ps-0;
}
.HyperMD-list-line-2 {
@apply !ps-6;
}
.HyperMD-list-line-3 {
@apply !ps-12;
}
</style>

View 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>

View File

@@ -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>

View File

@@ -1,111 +1,49 @@
<script lang="ts">
import type { RootContent, Root } from 'hast';
import { Text, Comment } from 'vue';
<template>
<div v-if="content && content.length > 0">
<ProsesRenderer #default v-if="data" :node="data" :proses="proses" />
</div>
</template>
import ProseP from '~/components/prose/ProseP.vue';
import ProseA from '~/components/prose/ProseA.vue';
import ProseBlockquote from '~/components/prose/ProseBlockquote.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 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';
<script setup lang="ts">
import type { Component } from 'vue';
import { heading } from 'hast-util-heading';
import { headingRank } from 'hast-util-heading-rank';
import { parseId } from '~/shared/general.util';
import type { Root } from 'hast';
const proseList = {
"p": ProseP,
"a": ProseA,
"blockquote": ProseBlockquote,
"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
};
const { content, proses, filter } = defineProps<{
content: string
proses?: Record<string, string | Component>
filter?: string
}>();
export default defineComponent({
name: 'MarkdownRenderer',
props: {
node: {
type: Object,
required: true
},
proses: {
type: Object,
default: () => ({})
}
},
async setup(props) {
if(props.proses)
const parser = useMarkdown(), data = ref<Root>();
const node = computed(() => content ? parser(content) : undefined);
watch([node], () => {
if(!node.value)
data.value = undefined;
else if(!filter)
{
for(const prose of Object.keys(props.proses))
data.value = node.value;
}
else
{
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;
const start = node.value?.children.findIndex(e => heading(e) && parseId(e.properties.id as string | undefined) === filter) ?? -1;
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(start === -1)
data.value = node.value;
else
{
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')
{
return h(Comment, node.value);
data.value = { ...node.value, children: node.value.children.slice(start, end) };
}
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>

View 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>

View File

@@ -1,5 +1,5 @@
<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'">
<img :src="src" />
</AvatarImage>
@@ -13,10 +13,18 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
const { src, icon, text } = defineProps<{
const { src, icon, text, size = 'medium' } = defineProps<{
src: string
icon?: string
text?: string
size?: keyof typeof SIZES
}>();
const loading = ref(true);
</script>
<script lang="ts">
const SIZES = {
'small': 'h-6',
'medium': 'h-10',
'large': 'h-16',
};
</script>

View File

@@ -1,5 +1,5 @@
<template>
<CollapsibleRoot v-model:open="model" :disabled="disabled">
<CollapsibleRoot v-model:open="model" :disabled="disabled" :defaultOpen="defaultOpen">
<div class="flex flex-row justify-center items-center">
<span v-if="!!label">{{ label }}</span>
<CollapsibleTrigger class="ms-4" asChild>
@@ -18,9 +18,10 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
const { label, disabled = false } = defineProps<{
const { label, disabled = false, defaultOpen = false } = defineProps<{
label?: string
disabled?: boolean
defaultOpen?: boolean
}>();
const model = defineModel<boolean>();
</script>

View 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>

View 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>

View 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>

View 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>

View File

@@ -1,10 +1,10 @@
<template>
<HoverCardRoot :open-delay="delay">
<HoverCardRoot :open-delay="delay" @update:open="(...args) => emits('open', ...args)">
<HoverCardTrigger class="inline-block cursor-help outline-none">
<slot></slot>
</HoverCardTrigger>
<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>
<HoverCardArrow class="fill-light-35 dark:fill-dark-35" />
</HoverCardContent>
@@ -13,9 +13,24 @@
</template>
<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
disabled?: boolean
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>

3
components/base/Kbd.vue Normal file
View 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>

View File

@@ -1,5 +1,5 @@
<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>
<script setup lang="ts">

View File

@@ -1,6 +1,6 @@
<template>
<Label class="py-4 flex flex-row justify-center items-center">
<span>{{ label }}</span>
<Label class="my-2 flex flex-1 items-center justify-between flex-col md:flex-row">
<span class="pb-1 md:p-0">{{ label }}</span>
<SelectRoot v-model="model">
<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
@@ -12,7 +12,7 @@
<SelectPortal :disabled="disabled">
<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>
<Icon icon="radix-icons:chevron-up" />
</SelectScrollUpButton>

View File

@@ -1,5 +1,6 @@
<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"
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

View 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>

View File

@@ -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
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"
: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>
</template>
@@ -16,5 +16,10 @@ const { type = 'text', label, disabled = false, placeholder } = defineProps<{
disabled?: boolean
placeholder?: string
}>();
const emits = defineEmits<{
change: [Event]
input: [Event]
}>();
const model = defineModel<string>();
</script>

View File

@@ -1,50 +1,20 @@
<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">
<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">
<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">
<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>
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 text-sm" :items="model" :get-key="getKey" :defaultExpanded="flatten(model)">
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level / 2 - 0.5}em` }" v-bind="item.bind" class="flex items-center ps-2 outline-none relative cursor-pointer">
<slot :isExpanded="isExpanded" :item="item" />
</TreeItem>
</TreeRoot>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
<script setup lang="ts" generic="T extends Record<string, any>">
const { getKey } = defineProps<{
getKey: (val: T) => string
}>();
interface TreeItem
const model = defineModel<T[]>();
function flatten(arr: T[]): string[]
{
label: string
link?: string
tag?: string
children?: TreeItem[]
return arr.filter(e => e.open).flatMap(e => [getKey(e), ...flatten(e.children ?? [])]);
}
const model = defineModel<TreeItem[]>();
</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>

View File

@@ -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>

View File

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

View File

@@ -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>

View File

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

View File

@@ -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>

296
components/page/Canvas.vue Normal file
View File

@@ -0,0 +1,296 @@
<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);
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef');
const updateScaleVar = useDebounceFn(() => {
if(transformRef.value)
{
transformRef.value.style.setProperty('--tw-scale', zoom.value.toString());
}
if(canvasRef.value)
{
canvasRef.value.style.setProperty('--zoom-multiplier', (1 / Math.pow(zoom.value, 0.7)).toFixed(3));
}
}, 100);
const reset = (_: MouseEvent) => {
zoom.value = minZoom.value;
dispX.value = 0;
dispY.value = 0;
updateTransform();
}
onMounted(() => {
let lastX = 0, lastY = 0, lastDistance = 0;
let box = canvasRef.value?.getBoundingClientRect()!;
const dragMove = (e: MouseEvent) => {
dispX.value = dispX.value - (lastX - e.clientX) / zoom.value;
dispY.value = dispY.value - (lastY - e.clientY) / zoom.value;
lastX = e.clientX;
lastY = e.clientY;
updateTransform();
};
const dragEnd = (e: MouseEvent) => {
window.removeEventListener('mouseup', dragEnd);
window.removeEventListener('mousemove', dragMove);
};
canvasRef.value?.addEventListener('mouseenter', () => {
window.addEventListener('wheel', cancelEvent, { passive: false });
document.addEventListener('gesturestart', cancelEvent);
document.addEventListener('gesturechange', cancelEvent);
canvasRef.value?.addEventListener('mouseleave', () => {
window.removeEventListener('wheel', cancelEvent);
document.removeEventListener('gesturestart', cancelEvent);
document.removeEventListener('gesturechange', cancelEvent);
});
})
window.addEventListener('resize', () => box = canvasRef.value?.getBoundingClientRect()!);
canvasRef.value?.addEventListener('mousedown', (e) => {
lastX = e.clientX;
lastY = e.clientY;
window.addEventListener('mouseup', dragEnd, { passive: true });
window.addEventListener('mousemove', dragMove, { passive: true });
}, { passive: true });
canvasRef.value?.addEventListener('wheel', (e) => {
if((zoom.value >= 3 && e.deltaY < 0) || (zoom.value <= minZoom.value && e.deltaY > 0))
return;
const diff = Math.exp(e.deltaY * -0.001);
const centerX = (box.x + box.width / 2), centerY = (box.y + box.height / 2);
const mousex = centerX - e.clientX, mousey = centerY - e.clientY;
dispX.value = dispX.value - (mousex / (diff * zoom.value) - mousex / zoom.value);
dispY.value = dispY.value - (mousey / (diff * zoom.value) - mousey / zoom.value);
zoom.value = clamp(zoom.value * diff, minZoom.value, 3)
updateTransform();
}, { passive: true });
canvasRef.value?.addEventListener('touchstart', (e) => {
({ x: lastX, y: lastY } = center(e.touches));
if(e.touches.length > 1)
{
lastDistance = distance(e.touches);
}
canvasRef.value?.addEventListener('touchend', touchend, { passive: true });
canvasRef.value?.addEventListener('touchcancel', touchcancel, { passive: true });
canvasRef.value?.addEventListener('touchmove', touchmove, { passive: true });
}, { passive: true });
const touchend = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: lastX, y: lastY } = center(e.touches));
}
canvasRef.value?.removeEventListener('touchend', touchend);
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
canvasRef.value?.removeEventListener('touchmove', touchmove);
};
const touchcancel = (e: TouchEvent) => {
if(e.touches.length > 1)
{
({ x: lastX, y: lastY } = center(e.touches));
}
canvasRef.value?.removeEventListener('touchend', touchend);
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
canvasRef.value?.removeEventListener('touchmove', touchmove);
};
const touchmove = (e: TouchEvent) => {
const pos = center(e.touches);
dispX.value = dispX.value - (lastX - pos.x) / zoom.value;
dispY.value = dispY.value - (lastY - pos.y) / zoom.value;
lastX = pos.x;
lastY = pos.y;
if(e.touches.length === 2)
{
const dist = distance(e.touches);
const diff = dist / lastDistance;
lastDistance = dist;
zoom.value = clamp(zoom.value * diff, minZoom.value, 3);
}
updateTransform();
};
updateTransform();
});
function updateTransform()
{
if(transformRef.value)
{
transformRef.value.style.transform = `scale3d(${zoom.value}, ${zoom.value}, 1) translate3d(${dispX.value}px, ${dispY.value}px, 0)`;
}
updateScaleVar();
}
</script>
<template>
<div ref="canvasRef" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none">
<div class="flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Zoom avant" side="right">
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:plus" />
</div>
</Tooltip>
<Tooltip message="Reset" side="right">
<div @click="zoom = 1; updateTransform();" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:reload" />
</div>
</Tooltip>
<Tooltip message="Tout contenir" side="right">
<div @click="reset" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:corners" />
</div>
</Tooltip>
<Tooltip message="Zoom arrière" side="right">
<div @click="zoom = clamp(zoom / 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
<Icon icon="radix-icons:minus" />
</div>
</Tooltip>
</div>
<NuxtLink v-if="overview && isOwner || hasPermissions(user?.permissions ?? [], ['admin', 'editor'])" :to="{ name: 'explore-edit', hash: `#${encodeURIComponent(overview!.path)}` }" class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
<Tooltip message="Modifier" side="right">
<Icon icon="radix-icons:pencil-1" class="w-8 h-8 p-2" />
</Tooltip>
</NuxtLink>
</div>
<div ref="transformRef" :style="{
'transform-origin': 'center center',
}" class="h-full">
<div v-if="canvas" class="absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none">
<div>
<CanvasNode v-for="node of canvas.nodes" :key="node.id" ref="nodes" :node="node" :zoom="zoom" />
</div>
<div>
<CanvasEdge v-for="edge of canvas.edges" :key="edge.id" ref="edges" :edge="edge" :nodes="canvas.nodes!" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -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>

View File

@@ -1,3 +1,27 @@
<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>
<span>
<span class="text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85"><slot v-bind="$attrs"></slot></span>
</span>
</HoverCard>
</span>
</template>
<script setup lang="ts">
import { parseURL } from 'ufo';
const { href } = defineProps<{
href: string
class?: string
}>();
const { hash, pathname } = parseURL(href);
const { content } = useContent();
const overview = computed(() => content.value.find(e => e.path === pathname));
</script>

View File

@@ -1,60 +1,36 @@
<template>
<NuxtLink no-prefetch class="text-accent-blue inline-flex items-center" v-if="data && data[0]"
:to="{ name: 'explore-path', params: { path: data[0].path }, hash: hash }" :class="class">
<HoverCard class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': data[0].type === 'canvas'}">
<NuxtLink class="text-accent-blue inline-flex items-center" :to="overview ? { name: 'explore-path', params: { path: overview.path }, hash: hash } : href" :class="class">
<HoverCard nuxt-client class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview?.type === 'canvas'}" :disabled="!overview">
<template #content>
<template v-if="data[0].type === 'markdown'">
<div class="px-10">
<Markdown :content="data[0].content" />
</div>
<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>
<template v-else-if="data[0].type === 'canvas'">
<div class="w-[600px] h-[600px] relative">
<Canvas :canvas="JSON.parse(data[0].content)" />
</div>
</template>
</template>
<template #default>
<span>
<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]" />
</template>
<Icon class="w-4 h-4 inline-block" v-if="overview && overview.type !== 'markdown'" :icon="iconByType[overview.type]" />
</span>
</HoverCard>
</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>
<script setup lang="ts">
import { parseURL } from 'ufo';
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<{
href: string
class?: string
}>();
const { hash, pathname, protocol } = parseURL(href);
const data = ref(), loading = ref(false);
const { hash, pathname } = parseURL(href);
if(!!pathname && !protocol)
{
loading.value = true;
try {
data.value = await $fetch(`/api/file`, {
query: {
search: `%${pathname}`
},
});
} catch(e) { }
loading.value = false;
}
const { content } = useContent();
const overview = computed(() => content.value.find(e => e.path === decodeURIComponent(pathname)));
</script>
<style>
.cm-link {
@apply text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85;
}
</style>

View File

@@ -1,179 +1,28 @@
<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 />
</blockquote>
</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>
blockquote:not(.callout)
{
@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
.HyperMD-quote
{
@apply before:hidden;
}
.callout {
@apply bg-light-blue;
@apply dark:bg-dark-blue;
}
.callout.is-collapsible .callout-title
.HyperMD-quote.hmd-inactive-line
{
@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;
}
.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;
@apply text-light-100 dark:text-dark-100;
}
</style>

View 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>

View File

@@ -1,3 +1,10 @@
<template>
<code><slot /></code>
</template>
<style>
.cm-inline-code
{
@apply !border-none !bg-transparent !text-light-100 dark:!text-dark-100 !p-0;
}
</style>

View File

@@ -1,9 +1,21 @@
<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 />
</h1>
</template>
<script setup lang="ts">
import { parseId } from '#shared/general.util';
const props = defineProps<{ id?: string }>()
</script>
<style>
.HyperMD-header-1
{
@apply text-5xl pt-4 pb-2 after:hidden;
}
.HyperMD-header-1 .cm-header
{
@apply font-thin;
}
</style>

View File

@@ -1,11 +1,23 @@
<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 />
</h2>
</template>
<script setup lang="ts">
import { parseId } from '#shared/general.util';
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)
</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>

View File

@@ -5,7 +5,19 @@
</template>
<script setup lang="ts">
import { parseId } from '#shared/general.util';
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)
</script>
<style>
.HyperMD-header-3
{
@apply !text-2xl !font-bold !pt-1 after:!hidden;
}
.HyperMD-header-3 .cm-header
{
@apply font-bold;
}
</style>

View File

@@ -5,5 +5,18 @@
</template>
<script setup lang="ts">
import { parseId } from '#shared/general.util';
const props = defineProps<{ id?: string }>()
</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>

View File

@@ -5,6 +5,7 @@
</template>
<script setup lang="ts">
import { parseId } from '#shared/general.util';
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)

View File

@@ -5,6 +5,7 @@
</template>
<script setup lang="ts">
import { parseId } from '#shared/general.util';
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)

View File

@@ -1,3 +1,10 @@
<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>
<style>
.HyperMD-hr
{
@apply bg-light-35 dark:bg-dark-35 h-px;
}
</style>

View File

@@ -1,3 +1,22 @@
<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>
</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>

View File

@@ -1,12 +1,3 @@
<template>
<p><slot /></p>
</template>
<style>
.text-comment
{
@apply text-light-50;
@apply dark:text-dark-50;
@apply text-sm;
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<small class="text-light-60 dark:text-dark-60 text-sm italic">
<slot />
</small>
</template>

View File

@@ -1,57 +1,16 @@
<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">
<slot></slot>
</span>
</template>
<!-- <script setup lang="ts">
import type { Tag } from '~/types/api';
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()
<style>
.cm-hashtag.cm-hashtag-begin
{
if(fetched.value)
return;
data.value = await $fetch(`/api/project/${project.value}/tags/${encodeURIComponent(tag)}`);
fetched.value = true;
@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;
}
</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
View 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);
});
}
}

View File

@@ -9,7 +9,7 @@ export default function useDatabase()
{
const database = useRuntimeConfig().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 foreign_keys = true;");

View File

@@ -4,10 +4,9 @@ import RemarkParse from "remark-parse";
import RemarkRehype from 'remark-rehype';
import RemarkOfm from 'remark-ofm';
import RemarkBreaks from 'remark-breaks'
import RemarkGfm from 'remark-gfm';
import RemarkBreaks from 'remark-breaks';
import RemarkFrontmatter from 'remark-frontmatter';
import RehypeRaw from 'rehype-raw';
export default function useMarkdown(): (md: string) => Root
{
@@ -17,8 +16,7 @@ export default function useMarkdown(): (md: string) => Root
if (!processor)
{
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]);
processor.use(RemarkRehype, { allowDangerousHtml: true });
processor.use(RehypeRaw);
processor.use(RemarkRehype);
}
const processed = processor.runSync(processor.parse(markdown)) as Root;

194
composables/useShortcuts.ts Normal file
View 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
}
}

View File

@@ -1,11 +1,12 @@
import type { UserSession, UserSessionComposable } from '~/types/auth'
import { useContent } from './useContent'
const useSessionState = () => useState<UserSession>('nuxt-session', () => ({}))
const useAuthReadyState = () => useState('nuxt-auth-ready', () => false)
const useContentFetch = (force: boolean) => useContent().fetch(force);
/**
* Composable to get back the user session and utils around it.
* @see https://github.com/atinux/nuxt-auth-utils
*/
export function useUserSession(): UserSessionComposable {
const sessionState = useSessionState()
@@ -13,28 +14,27 @@ export function useUserSession(): UserSessionComposable {
return {
ready: computed(() => authReadyState.value),
loggedIn: computed(() => Boolean(sessionState.value.user)),
user: computed(() => sessionState.value.user || null),
user: computed(() => sessionState.value.user ?? null),
session: sessionState,
fetch,
clear,
}
}
async function fetch() {
async function fetch(): Promise<boolean> {
const authReadyState = useAuthReadyState()
useSessionState().value = await useRequestFetch()('/api/auth/session', {
headers: {
Accept: 'text/json',
},
retry: false,
}).catch(() => ({}))
if (!authReadyState.value) {
const sessionState = useSessionState()
const loggedIn = Boolean(sessionState.value.user)
sessionState.value = await useRequestFetch()('/api/auth/session').catch(() => ({}))
if (!authReadyState.value)
{
authReadyState.value = true
}
return loggedIn !== Boolean(sessionState.value.user);
}
async function clear() {
await $fetch('/api/auth/session', { method: 'DELETE' })
await useRequestFetch()('/api/auth/session', { method: 'DELETE' })
useSessionState().value = {}
useRouter().go(0);
useRouter().go(0)
}

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -12,6 +12,8 @@ export const usersTable = sqliteTable("users", {
export const usersDataTable = sqliteTable("users_data", {
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
lastTimestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
logCount: int().notNull().default(0),
});
export const userSessionsTable = sqliteTable("user_sessions", {
@@ -37,12 +39,20 @@ export const explorerContentTable = sqliteTable("explorer_content", {
path: text().primaryKey(),
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
title: text().notNull(),
type: text({ enum: ['file', 'folder', 'markdown', 'canvas'] }).notNull(),
type: text({ enum: ['file', 'folder', 'markdown', 'canvas', 'map'] }).notNull(),
content: blob({ mode: 'buffer' }),
navigable: int({ mode: 'boolean' }).default(true),
private: int({ mode: 'boolean' }).default(false),
navigable: int({ mode: 'boolean' }).notNull().default(true),
private: int({ mode: 'boolean' }).notNull().default(false),
order: int().notNull(),
visit: int().notNull().default(0),
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
export const emailValidationTable = sqliteTable("email_validation", {
id: text().primaryKey(),
timestamp: int({ mode: 'timestamp' }).notNull(),
})
export const usersRelation = relations(usersTable, ({ one, many }) => ({
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
session: many(userSessionsTable),

View File

@@ -0,0 +1,2 @@
ALTER TABLE `explorer_content` ADD `order` integer;--> statement-breakpoint
CREATE UNIQUE INDEX `order` ON `explorer_content` (`order`);

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

View File

@@ -0,0 +1,4 @@
CREATE TABLE `email_validation` (
`id` text PRIMARY KEY NOT NULL,
`timestamp` integer NOT NULL
);

View 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": {}
}
}

View 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": {}
}
}

View 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": {}
}
}

View File

@@ -22,6 +22,27 @@
"when": 1730985155814,
"tag": "0002_messy_solo",
"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
}
]
}

View File

@@ -1,12 +1,15 @@
<template>
<Head>
<Title>d[any] - Erreur {{ error?.statusCode }}</Title>
</Head>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden justify-center items-center flex-col gap-4">
<NuxtRouteAnnouncer/>
<div class="flex gap-4 items-center">
<Icon icon="si:error-line" class="w-12 h-12 text-light-60 dark:text-dark-60"/>
<div class="text-3xl">Une erreur est survenue.</div>
</div>
<pre class="">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
<NuxtLink :href="{ name: 'index' }"><Button>Revenir en lieu sûr</Button></NuxtLink>
<pre class="text-center text-wrap">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
<Button @click="handleError">Revenir en lieu sûr</Button>
</div>
</template>

View File

@@ -1,51 +1,59 @@
<template>
<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="flex items-center px-2">
<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="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=closed]:hidden" icon="radix-icons:cross-1" />
</Button>
</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>
</div>
<div class="flex items-center px-2">
<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="hover:border-opacity-70 flex">
<Icon :icon="loggedIn ? 'radix-icons:avatar' : 'radix-icons:person'" class="w-7 h-7 p-1" />
</div>
<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>
</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 class="flex flex-1 flex-row relative h-screen overflow-hidden">
<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="relative bottom-6 flex flex-col gap-4 xl:px-6 px-3">
<div class="flex justify-between items-center max-md:hidden">
<NuxtLink class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-md:ps-6" 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" />
<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 flex-1 justify-between items-center py-4 px-2">
<NuxtLink :href="{ name: 'explore-path', params: { path: 'index' } }" class="flex flex-1 font-bold text-lg items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" active-class="text-accent-blue border-s-2 !border-accent-blue">
<span class="pl-3 py-1 flex-1 truncate">Projet</span>
</NuxtLink>
<div class="flex gap-4 items-center">
<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" />
<NuxtLink v-if="user && hasPermissions(user.permissions, ['admin', 'editor'])" :to="{ name: 'explore-edit' }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink>
</div>
<Tree v-if="pages" v-model="pages" :getKey="(item) => item.path" class="ps-4">
<template #default="{ item, isExpanded }">
<NuxtLink :href="item.value.path && !item.hasChildren ? { name: 'explore-path', params: { path: item.value.path } } : undefined" class="flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full" :class="{ 'font-medium': item.hasChildren }" active-class="text-accent-blue" :data-private="item.value.private">
<Icon v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level / 2 - 1.5}em` }" />
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="w-5 h-5" />
<div class="pl-1.5 py-1.5 flex-1 truncate">
{{ item.value.title }}
</div>
<Tooltip message="Privé" side="right"><Icon v-show="item.value.private" class="mx-1" icon="radix-icons:lock-closed" /></Tooltip>
</NuxtLink>
</Tooltip>
</template>
</Tree>
</div>
</div>
</div>
<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"/>
<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 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>
@@ -56,20 +64,38 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { iconByType } from '#shared/general.util';
import type { DropdownOption } from '~/components/base/DropdownMenu.vue';
import { hasPermissions } from '~/shared/auth.util';
import type { TreeItem } from '~/types/content';
const open = ref(true);
const { loggedIn } = useUserSession();
const options = ref<DropdownOption[]>([{
type: 'item',
label: 'Mon profil',
select: () => useRouter().push({ name: 'user-profile' }),
}, {
type: 'item',
label: 'Deconnexion',
select: () => clear(),
}]);
const { data: pages } = await useLazyFetch('/api/navigation', {
transform: transform,
});
const open = ref(false);
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;
});
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>

View File

@@ -1,3 +1,3 @@
<template>
Index
<slot></slot>
</template>

28
localhost+1-key.pem Normal file
View 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
View 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-----

View File

@@ -1,8 +1,14 @@
import { hasPermissions } from "#shared/auth.util";
export default defineNuxtRouteMiddleware(async (to, from) => {
const { loggedIn, fetch, user } = useUserSession();
const { fetch: fetchContent } = useContent();
const meta = to.meta;
await fetch();
if(await fetch())
{
fetchContent(true);
}
if(!!meta.guestsGoesTo && !loggedIn.value)
{

View File

@@ -1,4 +1,8 @@
// 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({
compatibilityDate: '2024-04-03',
modules: [
@@ -7,6 +11,7 @@ export default defineNuxtConfig({
'@nuxtjs/tailwindcss',
'@vueuse/nuxt',
'radix-vue/nuxt',
'@nuxtjs/sitemap',
],
tailwindcss: {
viewer: false,
@@ -98,8 +103,8 @@ export default defineNuxtConfig({
100: '#dadada',
},
accent: {
purple: '#8a5cf5',
blue: '#53aaf5',
purple: '#43A047',
blue: '#26C6DA',
},
}
}
@@ -116,23 +121,62 @@ export default defineNuxtConfig({
},
],
nitro: {
preset: 'bun',
experimental: {
tasks: true,
},
rollupConfig: {
external: ['bun'],
plugins: [
vuePlugin({ include: /\.vue$/, target: 'node' })
]
},
},
runtimeConfig: {
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: '',
user: '',
passwd: '',
dkim: '',
}
},
security: {
rateLimiter: false,
headers: {
contentSecurityPolicy: {
"img-src": "'self' data: blob:"
"img-src": "'self' data: blob:",
"base-uri": "localhost:*"
}
},
xssValidator: false,
},
sitemap: {
exclude: ['/admin/**', '/explore/edit', '/user/mailvalidated', '/user/changing-password', '/user/reset-password'],
sources: ['/api/__sitemap__/urls']
},
experimental: {
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'),
}
}
})

View File

@@ -3,27 +3,58 @@
"private": true,
"type": "module",
"scripts": {
"predev": "bun i && bunx nuxi cleanup",
"dev": "bunx --bun nuxi dev"
"predev": "bun i",
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 bunx --bun nuxi dev"
},
"dependencies": {
"@iconify/vue": "^4.1.2",
"@atlaskit/pragmatic-drag-and-drop": "^1.5.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@iconify/vue": "^4.3.0",
"@lezer/highlight": "^1.2.1",
"@markdoc/markdoc": "^0.5.1",
"@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/tailwindcss": "^6.12.2",
"@vueuse/nuxt": "^11.1.0",
"codemirror": "^6.0.1",
"drizzle-orm": "^0.35.3",
"nuxt": "^3.14.159",
"nuxt-security": "^2.0.0",
"radix-vue": "^1.9.8",
"vue": "latest",
"vue-router": "latest",
"zod": "^3.23.8"
"@nuxtjs/sitemap": "^7.2.5",
"@nuxtjs/tailwindcss": "^6.13.1",
"@types/codemirror": "^5.60.15",
"@vueuse/gesture": "^2.0.0",
"@vueuse/math": "^12.7.0",
"@vueuse/nuxt": "^12.7.0",
"codemirror": "5.65.18",
"drizzle-orm": "^0.39.3",
"hast": "^1.0.0",
"hast-util-heading": "^3.0.0",
"hast-util-heading-rank": "^3.0.0",
"hypermd": "^0.3.11",
"lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.2",
"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": {
"@types/bun": "^1.1.12",
"better-sqlite3": "^11.5.0",
"bun-types": "^1.1.34",
"drizzle-kit": "^0.26.2"
"@types/bun": "^1.2.2",
"@types/lodash.capitalize": "^4.2.9",
"@types/nodemailer": "^6.4.17",
"@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"
}
}

View File

@@ -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">
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({
rights: ['admin'],
})
const job = ref<string>('');
});
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
{
data.value = await $fetch(`/api/admin/jobs/${job.value}`, {
await $fetch(`/api/admin/user/${user.id}/permissions`, {
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)
{
status.value = 'error';
error.value = e;
err.value = true;
success.value = false;
toaster.add({
duration: 10000, type: 'error', content: (e as any).message, timer: true,
});
}
}
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>
<template>
<Head>
<Title>Administration</Title>
<Title>d[any] - Administration</Title>
</Head>
<div class="flex flex-col justify-start">
<ProseH2>Administration</ProseH2>
<Select label="Job" v-model="job">
<SelectItem label="Synchroniser" value="sync" />
<SelectItem label="Nettoyer la base" value="clear" disabled />
<SelectItem label="Reconstruire" value="rebuild" disabled />
</Select>
<Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'">
<span>Executer</span>
</Button>
<div class="flex flex-1 flex-col p-4">
<div class="flex flex-row justify-between items-center">
<ProseH2 class="text-center flex-1">Administration</ProseH2>
<Button><NuxtLink :to="{ name: 'admin-jobs' }">Jobs</NuxtLink></Button>
</div>
<div class="flex flex-1 w-full justify-center items-stretch flex-row gap-4">
<div class="flex-1">
<Collapsible v-if=users :label="`Utilisateurs (${users.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">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>
</template>

92
pages/admin/jobs.vue Normal file
View 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>

View File

@@ -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>

View File

@@ -1,35 +1,11 @@
<template>
<div v-if="status === 'pending'" class="flex">
<div class="flex flex-1 justify-start items-start" v-if="overview">
<Head>
<Title>d[any] - Chargement</Title>
<Title>d[any] - {{ overview.title }}</Title>
</Head>
<Loading />
</div>
<div class="flex flex-1 justify-start items-start" v-else-if="page">
<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>
<Markdown v-if="overview.type === 'markdown'" :path="path" />
<Canvas v-else-if="overview.type === 'canvas'" :path="path" />
<ProseH2 v-else class="flex-1 text-center">Impossible d'afficher le contenu demandé</ProseH2>
</div>
<div v-else>
<Head>
@@ -42,8 +18,7 @@
<script setup lang="ts">
const route = useRouter().currentRoute;
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
const { loggedIn, user } = useUserSession();
const { data: page, status, error } = await useFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [route, path], });
const isOwner = computed(() => user.value?.id === page.value?.owner);
const { content } = useContent();
const overview = computed(() => content.value.find(e => e.path === path.value));
</script>

View File

@@ -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>

View 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: '#' + encodeURIComponent(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, decodeURIComponent(value.hash.substring(1)));
else
selected.value = undefined;
}, { immediate: true });
</script>

View File

@@ -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>
<Head>
<Title>d[any] - Accueil</Title>
</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>
</div>
</template>

View File

@@ -1,4 +1,7 @@
<template>
<Head>
<Title>d[any] - Mentions légales</Title>
</Head>
<div class="flex flex-col max-w-[1200px] p-16">
<ProseH3>Mentions Légales</ProseH3>
<ProseH4>Collecte et Traitement des Données Personnelles</ProseH4>

53
pages/roadmap.vue Normal file
View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -1,6 +1,6 @@
<template>
<Head>
<Title>Connexion</Title>
<Title>d[any] - Connexion</Title>
</Head>
<div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center">
@@ -8,9 +8,10 @@
<ProseH4>Connexion</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="state.usernameOrEmail"/>
<TextInput type="password" label="Mot de passe" 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>
<TextInput type="text" label="Utilisateur ou email" name="username" autocomplete="username email" v-model="state.usernameOrEmail"/>
<TextInput type="password" label="Mot de passe" name="password" autocomplete="current-password" v-model="state.password"/>
<Button type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Se connecter</Button>
<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>
</form>
</div>

View File

@@ -1,14 +1,29 @@
<script setup lang="ts">
import { hasPermissions } from "#shared/auth.util";
definePageMeta({
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()
{
loading.value = true;
await $fetch(`/api/users/${user.value?.id}`, {
method: 'delete'
});
loading.value = false;
clear();
}
</script>
@@ -16,7 +31,7 @@ async function deleteUser()
<template>
<Head>
<Title>Mon profil</Title>
<Title>d[any] - Mon profil</Title>
</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="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
des droits de lecture.</span></template>
</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 class="flex flex-col self-center flex-1 gap-4">
<Button @click="async () => await clear()">Se deconnecter</Button>
<Button disabled><Tooltip message="En cours de développement">Modifier mon profil</Tooltip></Button>
<Button @click="clear">Se deconnecter</Button>
<NuxtLink :to="{ name: 'user-changing-password' }" class="flex flex-1"><Button>Modifier mon mot de passe</Button></NuxtLink>
<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
mon compte</Button></AlertDialogTrigger>
<AlertDialogPortal>
@@ -64,19 +79,5 @@ async function deleteUser()
</AlertDialogRoot>
<NuxtLink v-if="hasPermissions(user.permissions, ['admin'])" :href="{ name: 'admin' }" class="flex" no-prefetch><Button class="flex-1">Administration</Button></NuxtLink>
</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>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<Head>
<Title>Inscription</Title>
<Title>d[any] - Inscription</Title>
</Head>
<div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center">
@@ -8,25 +8,19 @@
<ProseH4>Inscription</ProseH4>
</div>
<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="email" label="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"/>
<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">
<span class="">Votre mot de passe doit respecter les critères de sécurité suivants
:</span>
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedLength}">Entre 8 et 128
caractères</span>
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedLowerUpper}">Au moins
une minuscule et une majuscule</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>
<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" name="email" autocomplete="email" v-model="state.email" 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="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="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>
</form>
</div>
@@ -52,7 +46,8 @@ const { add: addToast, clear: clearToasts } = useToast();
const confirmPassword = ref("");
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 checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));

7
plugins/autofocus.ts Normal file
View 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

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

View File

@@ -1 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://obsidian.peaceultime.com/sitemap.xml

View File

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

16
schemas/navigation.ts Normal file
View File

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

22
schemas/project.ts Normal file
View File

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

View File

@@ -29,7 +29,7 @@ function securePassword(password: string, ctx: z.RefinementCtx): void {
{
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Votre mot de passe doit contenir au moins un symbole",
message: "Votre mot de passe doit contenir au moins un caractère spécial",
});
}
}

View File

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

View File

@@ -1,3 +1,13 @@
import { hasPermissions } from "#shared/auth.util";
declare module 'nitropack'
{
interface TaskPayload
{
type: string
}
}
export default defineEventHandler(async (e) => {
const session = await getUserSession(e);
@@ -7,6 +17,7 @@ export default defineEventHandler(async (e) => {
return;
}
const id = getRouterParam(e, 'id');
const payload: Record<string, any> = await readBody(e);
if(!id)
{
@@ -14,12 +25,23 @@ export default defineEventHandler(async (e) => {
return;
}
const result = await runTask(id);
payload.type = id;
payload.data = JSON.parse(payload.data);
const result = await runTask(id, {
payload: payload
});
if(!result.result)
{
setResponseStatus(e, 500);
throw result.error ?? new Error('Erreur inconnue');
if(result.error && (result.error as Error).message)
throw result.error;
else if(result.error)
throw new Error(result.error);
else
throw new Error('Erreur inconnue');
}
return
return;
});

View File

@@ -0,0 +1,50 @@
import { ne, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { hasPermissions } from '~/shared/auth.util';
export default defineEventHandler(async (e) => {
const session = await getUserSession(e);
if(!session || !session.user || !hasPermissions(session.user.permissions, ['admin']))
{
throw createError({
statusCode: 401,
message: 'Unauthorized',
});
}
const db = useDatabase();
const content = db.select({
path: explorerContentTable.path,
owner: explorerContentTable.owner,
title: explorerContentTable.title,
type: explorerContentTable.type,
size: sql<number>`CASE WHEN ${explorerContentTable.content} IS NULL THEN 0 ELSE length(${explorerContentTable.content}) END`.as('size'),
navigable: explorerContentTable.navigable,
private: explorerContentTable.private,
order: explorerContentTable.order,
visit: explorerContentTable.visit,
timestamp: explorerContentTable.timestamp,
}).from(explorerContentTable).all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;
});
for(let i = 0; i < content.length; i++)
{
const path = content[i].path.substring(0, content[i].path.lastIndexOf('/'));
if(path !== '')
{
const parent = content.find(e => e.path === path);
if(parent)
{
content[i].private = content[i].private || parent.private;
}
}
}
return content.filter(e => e.type !== 'folder');
})

View File

@@ -0,0 +1,40 @@
import { hasPermissions } from "~/shared/auth.util";
import useDatabase from '~/composables/useDatabase';
import { and, eq, notInArray } from "drizzle-orm";
import { z } from "zod";
import { userSessionsTable } from "~/db/schema";
const schema = z.array(z.string());
export default defineEventHandler(async (e) => {
const session = await getUserSession(e);
if(!session || !session.user || !hasPermissions(session.user.permissions, ['admin']))
{
throw createError({
statusCode: 401,
message: 'Unauthorized',
});
}
const param = getRouterParam(e, 'id');
if(!param)
{
throw createError({
statusCode: 403,
message: 'Forbidden',
});
}
try {
const id = parseInt(param, 10);
const db = useDatabase();
db.delete(userSessionsTable).where(eq(userSessionsTable.user_id, id)).run();
} catch(e) {
console.error(e);
throw e;
}
});

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