Compare commits

..

No commits in common. "af317cb0e3f35126f226f3335f7635adafcecac6" and "896af11fa7d4fd3532d7f26f27418f9653ec4b88" have entirely different histories.

58 changed files with 427 additions and 4225 deletions

2656
bun.lock

File diff suppressed because it is too large Load Diff

BIN
bun.lockb Normal file

Binary file not shown.

View File

@ -1,636 +0,0 @@
<script lang="ts">
import { type Position } from '#shared/canvas.util';
import type CanvasNodeEditor from './canvas/CanvasNodeEditor.vue';
import type CanvasEdgeEditor from './canvas/CanvasEdgeEditor.vue';
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 dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
const focusing = ref<Element>(), editing = ref<Element>();
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef');
const nodes = useTemplateRef<NodeEditor[]>('nodes'), edges = useTemplateRef<EdgeEditor[]>('edges');
const canvasSettings = useCookie<{
snap: boolean,
size: number
}>('canvasPreference', { default: () => ({ snap: true, size: 32 }) });
const snap = computed({
get: () => canvasSettings.value.snap,
set: (value: boolean) => canvasSettings.value = { ...canvasSettings.value, snap: value },
}), gridSize = computed({
get: () => canvasSettings.value.size,
set: (value: number) => canvasSettings.value = { ...canvasSettings.value, size: value },
});
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);
const history = ref<HistoryEvent[]>([]);
const historyPos = ref(-1);
const historyCursor = computed(() => history.value.length > 0 && historyPos.value > -1 ? history.value[historyPos.value] : undefined);
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;
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) => {
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 = (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;
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)`;
transformRef.value.style.setProperty('--tw-scale', zoom.value.toString());
}
}
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)!;
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)!;
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 });
}
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);
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);
actions.push({ element: { type: 'node', id: element.id }, from: c.nodes!.splice(index, 1)[0], 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 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) console.log("Unselecting %s (%s)", focusing.value.id, focusing.value.type);
if(focusing.value !== undefined)
{
focused.value?.dom?.removeEventListener('click', stopPropagation);
focused.value?.unselect();
}
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);
canvas.value.nodes!.splice(index, 1);
break;
}
case 'remove':
{
const a = action as HistoryAction<'remove'>;
canvas.value.nodes!.push(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;
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);
break;
}
case 'remove':
{
const a = action as HistoryAction<'remove'>;
const index = canvas.value.nodes!.findIndex(e => e.id === action.element.id);
canvas.value.nodes!.splice(index, 1);
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;
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" :style="{ '--zoom-multiplier': (1 / Math.pow(zoom, 0.7)) }" @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="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>
<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 v-if="focusing !== undefined && focusing.type === 'node'" class="absolute z-20 origin-bottom" :style="{transform: `translate(${canvas.nodes!.find(e => e.id === focusing!.id)!.x}px, ${canvas.nodes!.find(e => e.id === focusing!.id)!.y}px) translateY(-100%) translateY(-12px) translateX(-50%) translateX(${canvas.nodes!.find(e => e.id === focusing!.id)!.width / 2}px) scale(calc(1 / var(--tw-scale)))`}">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 flex flex-row">
<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>
<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)" :snapping="snap" :grid="gridSize" />
</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)" />
</div>
</div>
</div>
</div>
</template>

View File

@ -84,14 +84,13 @@ watchEffect(() => {
</script> </script>
<template> <template>
<div ref="editor" class="flex flex-1 w-full justify-stretch items-stretch border border-light-35 dark:border-dark-35 caret-light-100 dark:caret-dark-100 py-2 px-1.5 font-sans text-base" /> <div ref="editor" class="flex flex-1 w-full justify-stretch items-stretch border border-light-35 dark:border-dark-35 caret-light-100 dark:caret-dark-100" />
</template> </template>
<style> <style>
.cm-editor .cm-editor
{ {
@apply bg-transparent; @apply bg-transparent;
@apply flex-1;
} }
.cm-editor .cm-content .cm-editor .cm-content
{ {

View File

@ -8,7 +8,7 @@
import type { Component } from 'vue'; import type { Component } from 'vue';
import { heading } from 'hast-util-heading'; import { heading } from 'hast-util-heading';
import { headingRank } from 'hast-util-heading-rank'; import { headingRank } from 'hast-util-heading-rank';
import { parseId } from '~/shared/general.util'; import { parseId } from '~/shared/general.utils';
import type { Root } from 'hast'; import type { Root } from 'hast';
const { content, proses, filter } = defineProps<{ const { content, proses, filter } = defineProps<{

View File

@ -1,5 +1,5 @@
<template> <template>
<AvatarRoot class="inline-flex select-none items-center justify-center overflow-hidden align-middle" :class="SIZES[size]"> <AvatarRoot class="inline-flex h-12 w-12 select-none items-center justify-center overflow-hidden align-middle">
<AvatarImage class="h-full w-full object-cover" :src="src" asChild @loading-status-change="(status) => loading = status === 'loading'"> <AvatarImage class="h-full w-full object-cover" :src="src" asChild @loading-status-change="(status) => loading = status === 'loading'">
<img :src="src" /> <img :src="src" />
</AvatarImage> </AvatarImage>
@ -13,18 +13,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
const { src, icon, text, size = 'medium' } = defineProps<{ const { src, icon, text } = defineProps<{
src: string src: string
icon?: string icon?: string
text?: string text?: string
size?: keyof typeof SIZES
}>(); }>();
const loading = ref(true); const loading = ref(true);
</script> </script>
<script lang="ts">
const SIZES = {
'small': 'h-6',
'medium': 'h-10',
'large': 'h-16',
};
</script>

View File

@ -1,6 +1,6 @@
<template> <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"> <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> <DraggableTreeItem v-for="item in flattenItems" :key="item._id" v-bind="item" class="flex items-center outline-none relative cursor-pointer hover:bg-light-20 dark:hover:bg-dark-20 data-[selected]:bg-light-35 dark:data-[selected]:bg-dark-35" @select.prevent @toggle.prevent>
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver }"> <template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver }">
<slot :handleToggle="handleToggle" <slot :handleToggle="handleToggle"
:handleSelect="handleSelect" :handleSelect="handleSelect"

View File

@ -1,7 +1,7 @@
<template> <template>
<template v-for="(item, idx) of options"> <template v-for="(item, idx) of options">
<template v-if="item.type === 'item'"> <template v-if="item.type === 'item'">
<DropdownMenuItem :disabled="item.disabled" :textValue="item.label" @select="item.select" :class="{'!pe-1': item.kbd}" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35"> <DropdownMenuItem :disabled="item.disabled" :textValue="item.label" @select="item.select" :class="{'!pe-1': item.kbd}" class="group 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" /> <Icon v-if="item.icon" :icon="item.icon" class="absolute left-1.5" />
<div class="flex flex-1 justify-between"> <div class="flex flex-1 justify-between">
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
@ -10,17 +10,14 @@
</DropdownMenuItem> </DropdownMenuItem>
</template> </template>
<!-- TODO -->
<template v-else-if="item.type === 'checkbox'"> <template v-else-if="item.type === 'checkbox'">
<DropdownMenuCheckboxItem :disabled="item.disabled" :textValue="item.label" v-model:checked="item.checked" @update:checked="item.select" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35"> <DropdownMenuCheckboxItem :disabled="item.disabled" :textValue="item.label" @update:checked="item.select">
<span class="w-6 flex items-center justify-center">
<DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
<Icon icon="radix-icons:check" /> <Icon icon="radix-icons:check" />
</DropdownMenuItemIndicator> </DropdownMenuItemIndicator>
</span>
<div class="flex flex-1 justify-between">
<span>{{ item.label }}</span> <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> <span v-if="item.kbd"> {{ item.kbd }} </span>
</div>
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
</template> </template>

View File

@ -4,7 +4,7 @@
<slot></slot> <slot></slot>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardPortal v-if="!disabled"> <HoverCardPortal v-if="!disabled">
<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" > <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" >
<slot name="content"></slot> <slot name="content"></slot>
<HoverCardArrow class="fill-light-35 dark:fill-dark-35" /> <HoverCardArrow class="fill-light-35 dark:fill-dark-35" />
</HoverCardContent> </HoverCardContent>
@ -13,24 +13,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { delay = 500, disabled = false, side = 'bottom', align = 'center', triggerKey } = defineProps<{ const { delay = 500, disabled = false, side = 'bottom' } = defineProps<{
delay?: number delay?: number
disabled?: boolean disabled?: boolean
side?: 'top' | 'right' | 'bottom' | 'left' side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
triggerKey?: string
}>(); }>();
const emits = defineEmits(['open']); const emits = defineEmits(['open'])
const canOpen = ref(true);
if(triggerKey)
{
const magicKeys = useMagicKeys();
const keys = magicKeys[triggerKey];
watch(keys, (v) => {
canOpen.value = v;
}, { immediate: true, });
}
</script> </script>

View File

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

@ -12,7 +12,7 @@
<SelectPortal :disabled="disabled"> <SelectPortal :disabled="disabled">
<SelectContent :position="position" <SelectContent :position="position"
class="min-w-[160px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-50"> class="min-w-[160px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-40">
<SelectScrollUpButton> <SelectScrollUpButton>
<Icon icon="radix-icons:chevron-up" /> <Icon icon="radix-icons:chevron-up" />
</SelectScrollUpButton> </SelectScrollUpButton>

View File

@ -1,6 +1,6 @@
<template> <template>
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 text-sm" :items="model" :get-key="getKey" :defaultExpanded="flatten(model)"> <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="getKey" :defaultExpanded="flatten(model)">
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level / 2 - 0.5}em` }" v-bind="item.bind" class="flex items-center ps-2 outline-none relative cursor-pointer"> <TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level - 0.5}em` }" v-bind="item.bind" class="flex items-center px-2 outline-none relative cursor-pointer">
<slot :isExpanded="isExpanded" :item="item" /> <slot :isExpanded="isExpanded" :item="item" />
</TreeItem> </TreeItem>
</TreeRoot> </TreeRoot>

View File

@ -1,9 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Direction, Path } from "~/shared/canvas.util";
import type { CanvasColor } from "~/types/canvas"; import type { CanvasColor } from "~/types/canvas";
type Direction = 'bottom' | 'top' | 'left' | 'right';
const props = defineProps<{ const props = defineProps<{
path: Path; path: {
path: string;
from: { x: number; y: number };
to: { x: number; y: number };
side: Direction;
};
color?: CanvasColor; color?: CanvasColor;
label?: string; label?: string;
}>(); }>();

View File

@ -1,95 +0,0 @@
<template>
<div class="absolute overflow-visible">
<input 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="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>
</div>
</template>
<script setup lang="ts">
import { getPath, labelCenter, type Direction } from '#shared/canvas.util';
import type { Element } from '../CanvasEditor.vue';
import type { CanvasEdge, CanvasNode } from '~/types/canvas';
const rotation: Record<Direction, string> = {
top: "180",
bottom: "0",
left: "90",
right: "270"
};
const { edge, nodes } = defineProps<{
edge: CanvasEdge
nodes: CanvasNode[]
}>();
const emit = defineEmits<{
(e: 'select', id: Element): void,
(e: 'edit', id: Element): void,
(e: 'move', id: string, from: string, to: string): 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;
console.log("Selecting %s (edge)", edge.id);
focusing.value = true;
emit('select', { type: 'edge', id: edge.id });
}
function edit(e: Event) {
oldText = edge.label;
console.log("Editing %s (edge)", edge.id);
focusing.value = true;
editing.value = true;
e.stopImmediatePropagation();
emit('edit', { type: 'edge', id: edge.id });
}
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 });
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,177 +0,0 @@
<template>
<div class="absolute" ref="dom" :style="{transform: `translate(${node.x}px, ${node.y}px)`, width: `${node.width}px`, height: `${node.height}px`, '--canvas-color': node.color?.hex}" :class="{'-z-10': node.type === 'group', 'z-10': node.type !== 'group'}">
<div v-if="!editing || node.type === 'group'" style="outline-style: solid;" :class="[style.border, style.outline, { '!outline-4 cursor-move': focusing }]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4">
<div class="w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto" :class="style.bg" @click.left="(e) => { if(node.type !== 'group') selectNode(e) }" @dblclick.left="(e) => { if(node.type !== 'group') editNode(e) }">
<div v-if="node.text?.length > 0" class="flex items-center">
<MarkdownRenderer :content="node.text" :proses="{ a: FakeA }" />
</div>
</div>
<div v-if="focusing">
<span @mousedown.left="(e) => resizeNode(e, 0, 1, 0, -1)" id="n " class="cursor-n-resize absolute -top-3 -right-3 -left-3 h-6 group">
<span @mousedown.left="(e) => dragEdge(e, 'top')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -top-1.5 left-1/2 -translate-x-3"></span>
</span> <!-- North -->
<span @mousedown.left="(e) => resizeNode(e, 0, 0, 0, 1)" id="s " class="cursor-s-resize absolute -bottom-3 -right-3 -left-3 h-6 group">
<span @mousedown.left="(e) => dragEdge(e, 'bottom')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -bottom-1.5 left-1/2 -translate-x-3"></span>
</span> <!-- South -->
<span @mousedown.left="(e) => resizeNode(e, 0, 0, 1, 0)" id="e " class="cursor-e-resize absolute -top-3 -bottom-3 -right-3 w-6 group">
<span @mousedown.left="(e) => dragEdge(e, 'right')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -right-1.5 top-1/2 -translate-y-3"></span>
</span> <!-- East -->
<span @mousedown.left="(e) => resizeNode(e, 1, 0, -1, 0)" id="w " class="cursor-w-resize absolute -top-3 -bottom-3 -left-3 w-6 group">
<span @mousedown.left="(e) => dragEdge(e, 'left')" :class="[style.bg]" class="hidden group-hover:block absolute rounded-full border-2 border-light-70 dark:border-dark-70 w-6 h-6 -left-1.5 top-1/2 -translate-y-3"></span>
</span> <!-- West -->
<span @mousedown.left="(e) => resizeNode(e, 1, 1, -1, -1)" id="nw" class="cursor-nw-resize absolute -top-4 -left-4 w-8 h-8"></span> <!-- North West -->
<span @mousedown.left="(e) => resizeNode(e, 0, 1, 1, -1)" id="ne" class="cursor-ne-resize absolute -top-4 -right-4 w-8 h-8"></span> <!-- North East -->
<span @mousedown.left="(e) => resizeNode(e, 0, 0, 1, 1)" id="se" class="cursor-se-resize absolute -bottom-4 -right-4 w-8 h-8"></span> <!-- South East -->
<span @mousedown.left="(e) => resizeNode(e, 1, 0, -1, 1)" id="sw" class="cursor-sw-resize absolute -bottom-4 -left-4 w-8 h-8"></span> <!-- South West -->
</div>
</div>
<div v-else style="outline-style: solid;" :class="[style.border, style.outline, { '!outline-4': focusing }]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 overflow-hidden contain-strict w-full h-full flex" >
<Editor v-model="node.text" />
</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()" 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>
<script setup lang="ts">
import { gridSnap, type 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, snapping, grid } = defineProps<{
node: CanvasNode
zoom: number
snapping: boolean
grid: number
}>();
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,
}>();
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 += (e.movementX / zoom) * x;
realy += (e.movementY / zoom) * y;
realw += (e.movementX / zoom) * w;
realh += (e.movementY / zoom) * h;
node.x = snapping ? gridSnap(realx, grid) : realx;
node.y = snapping ? gridSnap(realy, grid) : realy;
node.width = snapping ? gridSnap(realw, grid) : realw;
node.height = snapping ? gridSnap(realh, grid) : 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: Event, direction: Direction) {
e.stopImmediatePropagation();
}
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;
node.x = snapping ? gridSnap(realx, grid) : realx;
node.y = snapping ? gridSnap(realy, grid) : realy;
};
const dragend = (e: MouseEvent) => {
if(e.button !== 0)
return;
window.removeEventListener('mousemove', dragmove);
window.removeEventListener('mouseup', dragend);
if(node.x - lastx !== 0 && node.y - lasty !== 0)
emit('move', node.id, node.x - lastx, node.y - lasty);
};
const dragstart = (e: MouseEvent) => {
if(e.button !== 0)
return;
lastx = node.x, lasty = node.y;
realx = node.x, realy = node.y;
window.addEventListener('mousemove', dragmove, { passive: true });
window.addEventListener('mouseup', dragend, { passive: true });
};
defineExpose({ unselect, dom, id: node.id });
const style = computed(() => {
return node.color ? node.color?.class ?
{ bg: `bg-light-${node.color?.class} dark:bg-dark-${node.color?.class}`, border: `border-light-${node.color?.class} dark:border-dark-${node.color?.class}`, outline: `outline-light-${node.color?.class} dark:outline-dark-${node.color?.class}` } :
{ bg: `bg-colored`, border: `border-[color:var(--canvas-color)]`, outline: `outline-[color:var(--canvas-color)]` } :
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40`, outline: `outline-light-40 dark:outline-dark-40` }
});
</script>

View File

@ -11,23 +11,19 @@
</template> </template>
<svg class="absolute top-0 left-0 overflow-visible w-full h-full origin-top pointer-events-none"> <svg class="absolute top-0 left-0 overflow-visible w-full h-full origin-top pointer-events-none">
<CanvasEdge v-for="edge of canvas.edges" :key="edge.id" <CanvasEdge v-for="edge of canvas.edges" :key="edge.id"
:path="getPath(getNode(canvas.nodes, edge.fromNode)!, edge.fromSide, getNode(canvas.nodes, edge.toNode)!, edge.toSide)!" :path="getPath(getNode(canvas.nodes, edge.fromNode)!, edge.fromSide, getNode(canvas.nodes, edge.toNode)!, edge.toSide)"
:color="edge.color" :label="edge.label" /> :color="edge.color" :label="edge.label" />
</svg> </svg>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { labelCenter, getNode, getPath } from '#shared/canvas.util';
import type { CanvasContent } from '~/types/content';
import type { CanvasContent as Canvas } from '~/types/canvas';
const { path } = defineProps<{ const { path } = defineProps<{
path: string path: string
}>(); }>();
const { content, get } = useContent(); const { content, get } = useContent();
const overview = computed(() => content.value.find(e => e.path === path) as CanvasContent | undefined); const overview = computed(() => content.value.find(e => e.path === path));
const loading = ref(false); const loading = ref(false);
if(overview.value && !overview.value.content) if(overview.value && !overview.value.content)
@ -36,6 +32,90 @@ if(overview.value && !overview.value.content)
await get(path); await get(path);
loading.value = false; loading.value = false;
} }
const canvas = computed(() => overview.value && overview.value.content ? overview.value.content : undefined); const canvas = computed(() => overview.value && overview.value.content ? JSON.parse(overview.value.content) as CanvasContent : undefined);
</script> </script>
<script lang="ts">
import { clamp } from '~/shared/general.utils';
import type { CanvasContent, CanvasNode } from '~/types/canvas';
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(nodes: CanvasNode[], id: string): CanvasNode | undefined
{
return 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 getPath(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
};
}
</script>

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDrag, useHover, usePinch, useWheel } from '@vueuse/gesture'; import { useDrag, useHover, usePinch, useWheel } from '@vueuse/gesture';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import { clamp } from '#shared/general.util'; import { clamp } from '#shared/general.utils';
const { path } = defineProps<{ path: string }>(); const { path } = defineProps<{ path: string }>();
const { user } = useUserSession(); const { user } = useUserSession();
@ -70,18 +70,6 @@ dark:border-dark-yellow
dark:border-dark-green dark:border-dark-green
dark:border-dark-cyan dark:border-dark-cyan
dark:border-dark-purple 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
*/ */
@ -153,13 +141,7 @@ const pinchHandler = usePinch(({ offset: [z] }: { offset: number[] }) => {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div :style="{ <div :style="{transform: `scale(${zoom}) translate(${dispX}px, ${dispY}px)`}" >
'--tw-translate-x': `${dispX}px`,
'--tw-translate-y': `${dispY}px`,
'--tw-scale-x': `${zoom}`,
'--tw-scale-y': `${zoom}`,
transform: 'scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) translate3d(var(--tw-translate-x), var(--tw-translate-y), 0)'
}">
<CanvasRenderer :path="path" /> <CanvasRenderer :path="path" />
</div> </div>
</div> </div>

View File

@ -20,7 +20,7 @@ if(overview.value && !overview.value.content)
</script> </script>
<template> <template>
<div class="flex flex-1 justify-start items-start flex-col lg:px-16 xl:px-32 2xl:px-64 py-6"> <div class="flex flex-1 justify-start items-start flex-col xl:px-24 md:px-8 px-4 py-6">
<Loading v-if="loading" /> <Loading v-if="loading" />
<template v-else-if="overview"> <template v-else-if="overview">
<div v-if="!popover" class="flex flex-1 flex-row justify-between items-center"> <div v-if="!popover" class="flex flex-1 flex-row justify-between items-center">

View File

@ -1,27 +1,3 @@
<template> <template>
<span>
<HoverCard trigger-key="Ctrl" nuxt-client class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview?.type === 'canvas'}" :disabled="!overview">
<template #content>
<Markdown v-if="overview?.type === 'markdown'" class="!px-6" :path="pathname" :filter="hash.substring(1)" popover />
<template v-else-if="overview?.type === 'canvas'"><div class="w-[600px] h-[600px] relative"><Canvas :path="pathname" /></div></template>
</template>
<span>
<span class="text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85"><slot v-bind="$attrs"></slot></span> <span class="text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85"><slot v-bind="$attrs"></slot></span>
</span>
</HoverCard>
</span>
</template> </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

@ -2,7 +2,7 @@
<NuxtLink class="text-accent-blue inline-flex items-center" :to="overview ? { name: 'explore-path', params: { path: overview.path }, hash: hash } : href" :class="class"> <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"> <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 #content>
<Markdown v-if="overview?.type === 'markdown'" class="!px-6" :path="pathname" :filter="hash.substring(1)" popover /> <Markdown v-if="overview?.type === 'markdown'" class="!px-10" :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 v-else-if="overview?.type === 'canvas'"><div class="w-[600px] h-[600px] relative"><Canvas :path="pathname" /></div></template>
</template> </template>
<span> <span>
@ -16,7 +16,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { parseURL } from 'ufo'; import { parseURL } from 'ufo';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import { iconByType } from '#shared/general.util'; import { iconByType } from '#shared/general.utils';
const { href } = defineProps<{ const { href } = defineProps<{
href: string href: string

View File

@ -2,8 +2,8 @@
<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"> <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> <CollapsibleTrigger>
<div :class="{ 'cursor-pointer': fold !== undefined }" class="flex flex-row items-center justify-start ps-2"> <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" /> <Icon :icon="calloutIconByType[type] ?? defaultCalloutIcon" class="w-6 h-6 stroke-2 float-start me-2" />
<span v-if="title" class="block font-bold text-start">{{ title }}</span> <span v-if="title" class="block font-bold">{{ 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" /> <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> </div>
</CollapsibleTrigger> </CollapsibleTrigger>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,6 @@ import RemarkParse from "remark-parse";
import RemarkRehype from 'remark-rehype'; import RemarkRehype from 'remark-rehype';
import RemarkOfm from 'remark-ofm'; import RemarkOfm from 'remark-ofm';
import RemarkGfm from 'remark-gfm'; import RemarkGfm from 'remark-gfm';
import RemarkBreaks from 'remark-breaks';
import RemarkFrontmatter from 'remark-frontmatter'; import RemarkFrontmatter from 'remark-frontmatter';
export default function useMarkdown(): (md: string) => Root export default function useMarkdown(): (md: string) => Root
@ -15,7 +14,7 @@ export default function useMarkdown(): (md: string) => Root
const parse = (markdown: string) => { const parse = (markdown: string) => {
if (!processor) if (!processor)
{ {
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]); processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkFrontmatter]);
processor.use(RemarkRehype); processor.use(RemarkRehype);
} }

View File

@ -7,6 +7,7 @@ const useContentFetch = (force: boolean) => useContent().fetch(force);
/** /**
* Composable to get back the user session and utils around it. * Composable to get back the user session and utils around it.
* @see https://github.com/atinux/nuxt-auth-utils
*/ */
export function useUserSession(): UserSessionComposable { export function useUserSession(): UserSessionComposable {
const sessionState = useSessionState() const sessionState = useSessionState()

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -39,7 +39,7 @@ export const explorerContentTable = sqliteTable("explorer_content", {
path: text().primaryKey(), path: text().primaryKey(),
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
title: text().notNull(), title: text().notNull(),
type: text({ enum: ['file', 'folder', 'markdown', 'canvas', 'map'] }).notNull(), type: text({ enum: ['file', 'folder', 'markdown', 'canvas'] }).notNull(),
content: blob({ mode: 'buffer' }), content: blob({ mode: 'buffer' }),
navigable: int({ mode: 'boolean' }).notNull().default(true), navigable: int({ mode: 'boolean' }).notNull().default(true),
private: int({ mode: 'boolean' }).notNull().default(false), private: int({ mode: 'boolean' }).notNull().default(false),

View File

@ -1,59 +1,84 @@
<template> <template>
<CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open"> <CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open">
<div class="z-50 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2"> <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 gap-4"> <div class="flex items-center px-2">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button icon class="!bg-transparent group md:hidden"> <Button icon class="ms-2 !bg-transparent group">
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" /> <Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" />
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" /> <Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-opacity-70 m-2 flex items-center gap-4" aria-label="Accueil" :to="{ path: '/', force: true }"> <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>
<Avatar src="/logo.dark.svg" class="dark:block hidden" /> </div>
<Avatar src="/logo.light.svg" class="block dark:hidden" /> <div class="flex items-center px-2">
<span class="text-xl max-md:hidden">d[any]</span> <Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
<Tooltip v-if="!loggedIn" :message="'Se connecter'" side="right">
<NuxtLink :to="{ name: 'user-login' }">
<div class="hover:border-opacity-70 flex items-center">
<Icon :icon="'radix-icons:person'" class="w-7 h-7 p-1" />
</div>
</NuxtLink> </NuxtLink>
</Tooltip>
<Tooltip v-else :message="'Mon profil'" side="right">
<DropdownMenu :options="options" side="bottom" align="end">
<div class="hover:border-opacity-70 flex items-center">
<Icon :icon="'radix-icons:avatar'" class="w-7 h-7 p-1" />
</div> </div>
<div class="flex items-center gap-8 max-md:hidden"> </DropdownMenu>
<Tooltip message="Developpement en cours" side="bottom"><NuxtLink href="#" class="text-light-70 dark:text-dark-70">Parcourir les projets</NuxtLink></Tooltip> </Tooltip>
<Tooltip message="Developpement en cours" side="bottom"><NuxtLink href="#" class="text-light-70 dark:text-dark-70">Créer du contenu</NuxtLink></Tooltip>
</div>
<div class="flex items-center px-2 gap-4">
<template v-if="!loggedIn">
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">Se connecter</NuxtLink>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70 max-md:hidden" :to="{ name: 'user-register' }">Créer un compte</NuxtLink>
</template>
<template v-else>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
</template>
</div> </div>
</div> </div>
<div class="flex flex-1 flex-row relative h-screen overflow-hidden"> <div class="flex flex-1 flex-row relative h-screen overflow-hidden">
<CollapsibleContent asChild forceMount> <CollapsibleContent asChild forceMount>
<div class="bg-light-0 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="bg-light-0 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] max-md:z-40 max-md:data-[state=open]:left-0">
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden"> <div class="flex flex-col gap-4 xl:px-6 px-3 py-4">
<div class="flex flex-row flex-1 justify-between items-center py-4 px-2"> <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" />
</NuxtLink>
<div class="flex gap-4 items-center">
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
<Tooltip v-if="!loggedIn" :message="'Se connecter'" side="right">
<NuxtLink :to="{ name: 'user-login' }">
<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="'radix-icons:person'" class="w-7 h-7 p-1" />
</div>
</NuxtLink>
</Tooltip>
<Tooltip v-else :message="'Mon profil'" side="right">
<DropdownMenu :options="options" side="right" align="start">
<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="'radix-icons:avatar'" class="w-7 h-7 p-1" />
</div>
</DropdownMenu>
</Tooltip>
</div>
</div>
</div>
<div class="flex-1 xl:px-6 px-3 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex flex-row flex-1 justify-between items-center">
<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"> <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> <span class="pl-3 py-1 flex-1 truncate">Projet</span>
</NuxtLink> </NuxtLink>
<NuxtLink v-if="user && hasPermissions(user.permissions, ['admin', 'editor'])" :to="{ name: 'explore-edit' }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink> <NuxtLink v-if="user && hasPermissions(user.permissions, ['admin', 'editor'])" :to="{ name: 'explore-edit' }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink>
</div> </div>
<Tree v-if="pages" v-model="pages" :getKey="(item) => item.path" class="ps-4"> <Tree v-if="pages" v-model="pages" :getKey="(item) => item.path">
<template #default="{ item, isExpanded }"> <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"> <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" :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-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` }" />
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="w-5 h-5" /> <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"> <div class="pl-3 py-1 flex-1 truncate">
{{ item.value.title }} {{ item.value.title }}
</div> </div>
<Tooltip message="Privé" side="right"><Icon v-show="item.value.private" class="mx-1" icon="radix-icons:lock-closed" /></Tooltip> <Tooltip message="Privé" side="right"><Icon v-show="item.value.private" icon="radix-icons:lock-closed" /></Tooltip>
</NuxtLink> </NuxtLink>
</template> </template>
</Tree> </Tree>
</div> </div>
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60"> <div class="xl:px-12 px-6 py-4 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> <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> <p>Copyright Peaceultime - 2024</p>
</div> </div>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
@ -64,7 +89,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import { iconByType } from '#shared/general.util'; import { iconByType } from '#shared/general.utils';
import type { DropdownOption } from '~/components/base/DropdownMenu.vue'; import type { DropdownOption } from '~/components/base/DropdownMenu.vue';
import { hasPermissions } from '~/shared/auth.util'; import { hasPermissions } from '~/shared/auth.util';
import type { TreeItem } from '~/types/content'; import type { TreeItem } from '~/types/content';

View File

@ -1,28 +0,0 @@
-----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-----

View File

@ -1,25 +0,0 @@
-----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,7 +1,5 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
import vuePlugin from 'rollup-plugin-vue' import vuePlugin from 'rollup-plugin-vue'
import fs from 'node:fs'
import path from 'node:path'
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2024-04-03', compatibilityDate: '2024-04-03',
@ -170,11 +168,5 @@ export default defineNuxtConfig({
} }
} }
} }
},
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

@ -4,52 +4,51 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"predev": "bun i", "predev": "bun i",
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 bunx --bun nuxi dev" "dev": "bunx --bun nuxi dev"
}, },
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0", "@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@iconify/vue": "^4.3.0", "@iconify/vue": "^4.3.0",
"@nuxtjs/color-mode": "^3.5.2", "@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/sitemap": "^7.2.3", "@nuxtjs/sitemap": "^7.0.1",
"@nuxtjs/tailwindcss": "^6.13.1", "@nuxtjs/tailwindcss": "^6.12.2",
"@vueuse/gesture": "^2.0.0", "@vueuse/gesture": "^2.0.0",
"@vueuse/math": "^12.5.0", "@vueuse/math": "^11.3.0",
"@vueuse/nuxt": "^12.5.0", "@vueuse/nuxt": "^11.3.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.35.3",
"hast": "^1.0.0", "hast": "^1.0.0",
"hast-util-heading": "^3.0.0", "hast-util-heading": "^3.0.0",
"hast-util-heading-rank": "^3.0.0", "hast-util-heading-rank": "^3.0.0",
"lodash.capitalize": "^4.2.1", "lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.2", "mdast-util-find-and-replace": "^3.0.2",
"nodemailer": "^6.10.0", "nodemailer": "^6.9.16",
"nuxt": "3.15.1", "nuxt": "^3.15.0",
"nuxt-security": "^2.1.5", "nuxt-security": "^2.1.5",
"radix-vue": "^1.9.12", "radix-vue": "^1.9.12",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-ofm": "link:remark-ofm",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1", "remark-rehype": "^11.1.1",
"rollup-plugin-postcss": "^4.0.2", "rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-vue": "^6.0.0", "rollup-plugin-vue": "^6.0.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"vue": "^3.5.13", "vue": "latest",
"vue-router": "^4.5.0", "vue-router": "latest",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.2.0", "@types/bun": "^1.1.14",
"@types/lodash.capitalize": "^4.2.9", "@types/lodash.capitalize": "^4.2.9",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/unist": "^3.0.3", "@types/unist": "^3.0.3",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.7.0",
"bun-types": "^1.2.0", "bun-types": "^1.1.42",
"drizzle-kit": "^0.30.2", "drizzle-kit": "^0.26.2",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"rehype-stringify": "^10.0.1" "rehype-stringify": "^10.0.1"
} }

View File

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

View File

@ -5,37 +5,60 @@
<ClientOnly> <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"> <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>
<div class="z-50 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2"> <div class="z-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 gap-4"> <div class="flex items-center px-2">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button icon class="!bg-transparent group md:hidden"> <Button icon class="ms-2 !bg-transparent group">
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" /> <Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" />
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" /> <Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-opacity-70 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>
<div class="flex items-center px-2 gap-4"> <div class="flex items-center px-2">
<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> <Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
</div> </div>
</div> </div>
<div class="flex flex-1 flex-row relative overflow-hidden"> <div class="flex flex-1 flex-row relative overflow-hidden">
<CollapsibleContent asChild forceMount> <CollapsibleContent asChild forceMount>
<div class="bg-light-0 dark:bg-dark-0 z-40 w-screen md:w-[18rem] border-r border-light-30 dark:border-dark-30 flex flex-col justify-between my-2 max-md:data-[state=closed]:hidden"> <div class=" overflow-hidden bg-light-0 dark:bg-dark-0 z-40 xl:w-96 md:w-[15em] max-h-full 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] max-md:z-40 max-md:data-[state=open]:left-0">
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden"> <div class="flex flex-col gap-4 xl:px-6 px-3 py-4">
<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"> <div class="flex justify-between items-center max-md:hidden">
<Button @click="router.push({ name: 'explore-path', params: { path: selected ? getPath(selected) : 'index' } })">Quitter</Button> <div class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-md:ps-6" aria-label="Accueil">
<Button @click="save(true);">Enregistrer</Button> <Avatar src="/logo.dark.svg" class="dark:block hidden" />
<Avatar src="/logo.light.svg" class="block dark:hidden" />
</div>
<div class="flex gap-4 items-center">
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
</div>
</div>
</div>
<div class="flex flex-1 flex-col max-w-full max-h-full overflow-hidden py-3" v-if="navigation">
<div class="flex flex-row justify-between items-center mb-4 px-6">
<div class="flex flex-1 flex-row justify-start items-center gap-4">
<Tooltip side="top" message="Annuler (Ctrl+Shift+W)" ><Button icon @click="router.go(-1)"><Icon class="w-5 h-5" icon="radix-icons:arrow-left" /></Button></Tooltip>
<Tooltip side="top" message="Enregistrer (Ctrl+S)" ><Button icon :loading="saveStatus === 'pending'" @click="save(true)"><Icon class="w-5 h-5" icon="radix-icons:check" /></Button></Tooltip>
<span v-if="edited" class="text-sm text-light-60 dark:text-dark-60 italic">Modifications non enregistrées</span>
</div>
<div class="flex flex-row justify-end items-center gap-4">
<AlertDialogRoot v-if="selected">
<Tooltip side="top" message="Supprimer"><AlertDialogTrigger as="span"><Button icon 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" ><Icon class="w-5 h-5" icon="radix-icons:trash" /></Button></AlertDialogTrigger></Tooltip>
<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 flex md:flex-row flex-col gap-4 items-center">
<AlertDialogTitle class="text-xl font-semibold">Supprimer <span>{{ selected.title }}</span><span v-if="selected.children"> et tous ces enfants</span> ?</AlertDialogTitle>
<div class="flex flex-1 flex-row gap-4 justify-end">
<AlertDialogAction asChild @click="navigation = tree.remove(navigation, getPath(selected))"><Button class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<Tooltip side="top" message="Nouveau"> <Tooltip side="top" message="Nouveau">
<DropdownMenu align="end" side="bottom" :options="[{ <DropdownMenu align="center" side="bottom" :options="[{
type: 'item', type: 'item',
label: 'Markdown', label: 'Markdown',
kbd: 'Ctrl+N', kbd: 'Ctrl+N',
icon: 'radix-icons:file-text', icon: 'radix-icons:file',
select: () => add('markdown'), select: () => add('markdown'),
}, { }, {
type: 'item', type: 'item',
@ -48,31 +71,28 @@
label: 'Canvas', label: 'Canvas',
icon: 'ph:graph-light', icon: 'ph:graph-light',
select: () => add('canvas'), select: () => add('canvas'),
}, {
type: 'item',
label: 'Carte',
icon: 'lucide:map',
select: () => add('map'),
}, { }, {
type: 'item', type: 'item',
label: 'Fichier', label: 'Fichier',
icon: 'radix-icons:file', icon: 'radix-icons:file-text',
select: () => add('file'), select: () => add('file'),
}]"> }]">
<Button icon><Icon class="w-5 h-5" icon="radix-icons:plus" /></Button> <Button icon><Icon class="w-5 h-5" icon="radix-icons:plus" /></Button>
</DropdownMenu> </DropdownMenu>
</Tooltip> </Tooltip>
</div> </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>
<DraggableTree class="ps-4 pe-2 xl:text-base text-sm"
:items="navigation ?? undefined" :get-key="(item: Partial<TreeItemEditable>) => item.path !== undefined ? getPath(item as TreeItemEditable) : ''" @updateTree="drop"
v-model="selected" :defaultExpanded="defaultExpanded" >
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, item }">
<div class="flex flex-1 items-center px-2 max-w-full pe-4" :class="{ 'opacity-50': isDragging }" :style="{ 'padding-left': `${item.level - 0.5}em` }">
<span class="py-2 px-2" @click="handleToggle" v-if="item.hasChildren" >
<Icon :icon="isExpanded ? 'lucide:folder-open' : 'lucide:folder'"/>
</span>
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="group-[:hover]:text-accent-purple mx-2" @click="handleSelect" />
<div class="pl-3 py-1 flex-1 truncate" :title="item.value.title" @click="handleSelect" :class="{ 'font-semibold': item.hasChildren }">
{{ item.value.title }}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<span @click="item.value.private = !item.value.private"> <span @click="item.value.private = !item.value.private">
@ -88,7 +108,7 @@
</template> </template>
<template #hint="{ instruction }"> <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="{ <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)` width: `calc(100% - ${instruction.currentLevel - 1}em)`
}" :class="{ }" :class="{
'!border-b-4': instruction?.type === 'reorder-below', '!border-b-4': instruction?.type === 'reorder-below',
'!border-t-4': instruction?.type === 'reorder-above', '!border-t-4': instruction?.type === 'reorder-above',
@ -97,110 +117,50 @@
</template> </template>
</DraggableTree> </DraggableTree>
</div> </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> </div>
</CollapsibleContent> </CollapsibleContent>
<div class="flex flex-1 flex-row max-h-full overflow-hidden"> <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"> <div v-if="selected" class="xl:px-12 xl:py-8 lg:px-8 lg:py-6 px-6 py-3 flex flex-1 flex-col items-start justify-start max-h-full relative">
<Head> <Head>
<Title>d[any] - Modification de {{ selected.title }}</Title> <Title>d[any] - Modification de {{ selected.title }}</Title>
</Head> </Head>
<CollapsibleRoot v-model:open="topOpen" class="group data-[state=open]:mt-4 w-full relative"> <div>
<CollapsibleTrigger asChild> <input type="text" v-model="selected.title" placeholder="Titre" class="inline md:h-16 h-12 w-full 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 py-1 md:text-5xl text-4xl font-thin bg-transparent" />
<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> <div class="flex flex-col justify-start items-start">
<Icon v-if="topOpen" icon="radix-icons:caret-up" class="h-4 w-4" /> <Switch label="Chemin personnalisé" v-model="selected.customPath" />
<Icon v-else icon="radix-icons:caret-down" class="h-4 w-4" /> <span>
</Button> <pre v-if="selected.customPath" class="flex md:items-center md:text-base md:text-nowrap text-wrap md:flex-nowrap flex-wrap text-sm">/{{ selected.parent !== '' ? selected.parent + '/' : '' }}<TextInput v-model="selected.name" @input="(e) => {
</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) if(selected && selected.customPath)
{ {
selected.name = parsePath(selected.name); selected.name = parsePath(selected.name);
rebuildPath(selected.children, getPath(selected)); rebuildPath(selected.children, getPath(selected));
} }
}" class="mx-0 font-mono"/> }" class="mx-0"/></pre>
</div> <pre v-else class="md:text-base text-smmd:text-nowrap text-wrap ">/{{ getPath(selected) }}</pre>
<pre v-else class="md:text-base text-sm truncate" style="direction: rtl">{{ getPath(selected) }}/</pre> </span>
<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> </div>
</div> <div class="py-4 flex-1 w-full max-h-full flex overflow-hidden">
</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'"> <template v-if="selected.type === 'markdown'">
<div v-if="contentStatus === 'pending'" class="flex flex-1 justify-center items-center"> <div v-if="contentStatus === 'pending'" class="flex flex-1 justify-center items-center">
<Loading /> <Loading />
</div> </div>
<span v-else-if="contentError">{{ contentError }}</span> <span v-else-if="contentError">{{ contentError }}</span>
<template v-else-if="preferences.markdown.editing === 'editing'"> <SplitterGroup direction="horizontal" class="flex-1 w-full flex" v-else-if="selected.content !== undefined">
<Editor v-model="selected.content" placeholder="Commencer votre aventure ..." 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"> <SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }" :defaultSize="50">
<Editor v-model="selected.content" placeholder="Commencer votre aventure ..." class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto" :class="{ 'hidden': isCollapsed }" /> <Editor v-model="selected.content" placeholder="Commencer votre aventure ..." class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto" :class="{ 'hidden': isCollapsed }" />
</SplitterPanel> </SplitterPanel>
<SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" /> <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 }"> <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> <div class="flex-1 max-h-full !overflow-y-auto px-8" :class="{ 'hidden': isCollapsed }"><MarkdownRenderer :content="debounced" :proses="{ 'a': FakeA }" /></div>
</SplitterPanel> </SplitterPanel>
</SplitterGroup> </SplitterGroup>
</template> </template>
</template>
<template v-else-if="selected.type === 'canvas'"> <template v-else-if="selected.type === 'canvas'">
<CanvasEditor v-if="selected.content" :modelValue="selected.content" /> <span class="flex flex-1 justify-center items-center"><ProseH3>Editeur de graphe en cours de développement</ProseH3></span>
</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>
<template v-else-if="selected.type === 'file'"> <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)" /> <span>Modifier le contenu :</span><input type="file" @change="(e) => console.log(e)" />
</template> </template>
</div> </div>
</div> </div>
@ -214,12 +174,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item'; 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 { parsePath } from '#shared/general.utils';
import type { ExploreContent, FileType, TreeItem } from '~/types/content'; import type { FileType, TreeItem } from '~/types/content';
import { iconByType } from '#shared/general.utils';
import FakeA from '~/components/prose/FakeA.vue'; import FakeA from '~/components/prose/FakeA.vue';
import type { Preferences } from '~/types/general';
export type TreeItemEditable = TreeItem & export interface TreeItemEditable extends TreeItem
{ {
parent: string; parent: string;
name: string; name: string;
@ -232,10 +192,8 @@ definePageMeta({
layout: 'null', layout: 'null',
}); });
const { user } = useUserSession();
const router = useRouter(); const router = useRouter();
const open = ref(true), topOpen = ref(true); const open = ref(true);
const toaster = useToast(); const toaster = useToast();
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle'); const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
@ -245,8 +203,6 @@ const navigation = ref<TreeItemEditable[]>(transform(JSON.parse(JSON.stringify(p
const selected = ref<TreeItemEditable>(), edited = ref(false); const selected = ref<TreeItemEditable>(), edited = ref(false);
const contentStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), contentError = ref<string>(); 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) => { watch(selected, async (value, old) => {
if(selected.value) if(selected.value)
{ {
@ -259,7 +215,7 @@ watch(selected, async (value, old) => {
if(storedEdit) if(storedEdit)
{ {
selected.value.content = convertContentFromText(selected.value.type, storedEdit); selected.value.content = storedEdit;
contentStatus.value = 'success'; contentStatus.value = 'success';
} }
else else
@ -294,13 +250,13 @@ const debounced = useDebounce(content, 250, { maxWait: 500 });
watch(debounced, () => { watch(debounced, () => {
if(selected.value && debounced.value) if(selected.value && debounced.value)
sessionStorage.setItem(`editing:${encodeURIComponent(selected.value.path)}`, typeof debounced.value === 'string' ? debounced.value : JSON.stringify(debounced.value)); sessionStorage.setItem(`editing:${encodeURIComponent(selected.value.path)}`, debounced.value);
}); });
useShortcuts({ useShortcuts({
meta_s: { usingInput: true, handler: () => save(false), prevent: true }, meta_s: { usingInput: true, handler: () => save(false) },
meta_n: { usingInput: true, handler: () => add('markdown'), prevent: true }, meta_n: { usingInput: true, handler: () => add('markdown') },
meta_shift_n: { usingInput: true, handler: () => add('folder'), prevent: true }, meta_shift_n: { usingInput: true, handler: () => add('folder') },
meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore-path', params: { path: 'index' } }), prevent: true } meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore-path', params: { path: 'index' } }) }
}) })
const tree = { const tree = {
@ -430,7 +386,7 @@ function add(type: FileType): void
const news = [...tree.search(navigation.value, 'title', 'Nouveau')].filter((e, i, a) => a.indexOf(e) === i); 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 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 }; const item: TreeItemEditable = { navigable: true, private: false, parent: '', path: '', title: title, name: parsePath(title), type: type, order: 0, children: type === 'folder' ? [] : undefined, customPath: false, content: type === 'markdown' ? '' : undefined, owner: -1, timestamp: new Date(), visit: 0 };
if(!selected.value) if(!selected.value)
{ {
@ -530,29 +486,27 @@ function rebuildPath(tree: TreeItemEditable[] | null | undefined, parentPath: st
} }
async function save(redirect: boolean): Promise<void> 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'; saveStatus.value = 'pending';
try { try {
const result = await $fetch(`/api/project`, { const result = await $fetch(`/api/project`, {
method: 'post', method: 'post',
body: map(navigation.value), body: navigation.value,
}); });
saveStatus.value = 'success'; saveStatus.value = 'success';
edited.value = false; edited.value = false;
sessionStorage.clear(); sessionStorage.clear();
toaster.clear('error'); toaster.clear('error');
toaster.add({ type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000 }); toaster.add({
type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000
});
//@ts-ignore complete.value = result;
complete.value = result as ExploreContent[];
if(redirect) router.go(-1); if(redirect) router.go(-1);
} catch(e: any) { } catch(e: any) {
toaster.add({ toaster.add({
type: 'error', content: e.message, timer: true, duration: 10000 type: 'error', content: e.message, timer: true, duration: 10000
}) })
console.error(e);
saveStatus.value = 'error'; saveStatus.value = 'error';
} }
} }

View File

@ -2,5 +2,9 @@
<Head> <Head>
<Title>d[any] - Accueil</Title> <Title>d[any] - Accueil</Title>
</Head> </Head>
<div class="h-full w-full flex flex-1 flex-col justify-center items-center">
<Avatar src="/logo.dark.svg" class="dark:block hidden w-48 h-48" />
<Avatar src="/logo.light.svg" class="block dark:hidden w-48 h-48" />
<h1 class="text-5xl font-thin font-mono">Bienvenue</h1> <h1 class="text-5xl font-thin font-mono">Bienvenue</h1>
</div>
</template> </template>

View File

@ -8,8 +8,8 @@
<ProseH4>Modification de mon mot de passe</ProseH4> <ProseH4>Modification de mon mot de passe</ProseH4>
</div> </div>
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch"> <form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
<TextInput type="password" label="Ancien mot de passe" name="old-password" autocomplete="current-password" v-model="oldPasswd"/> <TextInput type="password" label="Ancien mot de passe" autocomplete="currentPassword" 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 }"/> <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"> <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="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': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
@ -18,8 +18,8 @@
<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': !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> <span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
</div> </div>
<TextInput type="password" label="Repeter le nouveau mot de passe" autocomplete="new-password" v-model="repeatPasswd" :class="{ 'border-light-red dark:border-dark-red': manualError }"/> <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 type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Mettre à jour mon mot de passe</Button> <Button class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Mettre à jour mon mot de passe</Button>
</form> </form>
</div> </div>
</template> </template>

View File

@ -8,9 +8,9 @@
<ProseH4>Connexion</ProseH4> <ProseH4>Connexion</ProseH4>
</div> </div>
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch"> <form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">
<TextInput type="text" label="Utilisateur ou email" name="username" autocomplete="username email" v-model="state.usernameOrEmail"/> <TextInput type="text" label="Utilisateur ou email" autocomplete="username" v-model="state.usernameOrEmail"/>
<TextInput type="password" label="Mot de passe" name="password" autocomplete="current-password" v-model="state.password"/> <TextInput type="password" label="Mot de passe" autocomplete="current-password" v-model="state.password"/>
<Button type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Se connecter</Button> <Button 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-reset-password' }">Mot de passe oublié ?</NuxtLink>
<NuxtLink class="mt-4 text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-register' }">Pas de compte ?</NuxtLink> <NuxtLink class="mt-4 text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-register' }">Pas de compte ?</NuxtLink>
</form> </form>

View File

@ -8,9 +8,9 @@
<ProseH4>Inscription</ProseH4> <ProseH4>Inscription</ProseH4>
</div> </div>
<form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0"> <form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0">
<TextInput type="text" label="Nom d'utilisateur" name="username" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/> <TextInput type="text" label="Nom d'utilisateur" 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="email" label="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"/> <TextInput type="password" label="Mot de passe" 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"> <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="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': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
@ -20,7 +20,7 @@
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span> <span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
</div> </div>
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/> <TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
<Button type="submit" class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button> <Button class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
<span class="mt-4 order-10 flex justify-center items-center gap-4 col-span-2 md:col-span-1 m-auto">Vous avez déjà un compte ?<NuxtLink class="text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-login' }">Se connecter</NuxtLink></span> <span class="mt-4 order-10 flex justify-center items-center gap-4 col-span-2 md:col-span-1 m-auto">Vous avez déjà un compte ?<NuxtLink class="text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-login' }">Se connecter</NuxtLink></span>
</form> </form>
</div> </div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 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,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
export const fileType = z.enum(['folder', 'file', 'markdown', 'canvas', 'map']); export const fileType = z.enum(['folder', 'file', 'markdown', 'canvas']);
export const schema = z.object({ export const schema = z.object({
path: z.string(), path: z.string(),
owner: z.number().finite(), owner: z.number().finite(),

View File

@ -17,6 +17,7 @@ export const item: z.ZodType<ProjectItem> = baseItem.extend({
}); });
export const project = z.array(item); export const project = z.array(item);
export type ProjectItem = z.infer<typeof baseItem> & { type Project = z.infer<typeof project>;
type ProjectItem = z.infer<typeof baseItem> & {
children?: ProjectItem[] children?: ProjectItem[]
}; };

View File

@ -1,7 +1,7 @@
import useDatabase from '~/composables/useDatabase'; import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema'; import { explorerContentTable } from '~/db/schema';
import { schema } from '~/schemas/file'; import { schema } from '~/schemas/file';
import { parsePath } from '~/shared/general.util'; import { parsePath } from '~/shared/general.utils';
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const body = await readValidatedBody(e, schema.safeParse); const body = await readValidatedBody(e, schema.safeParse);

View File

@ -1,7 +1,6 @@
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase'; import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema'; import { explorerContentTable } from '~/db/schema';
import { convertContentFromText } from '~/shared/general.util';
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const path = decodeURIComponent(getRouterParam(e, "path") ?? ''); const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
@ -17,7 +16,6 @@ export default defineEventHandler(async (e) => {
const content = db.select({ const content = db.select({
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'), 'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
'private': explorerContentTable.private, 'private': explorerContentTable.private,
'type': explorerContentTable.type,
'owner': explorerContentTable.owner, 'owner': explorerContentTable.owner,
'visit': explorerContentTable.visit, 'visit': explorerContentTable.visit,
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path }); }).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
@ -26,7 +24,12 @@ export default defineEventHandler(async (e) => {
{ {
const session = await getUserSession(e); const session = await getUserSession(e);
if(!session || !session.user || session.user.id !== content.owner) if(content.private && (!session || !session.user))
{
setResponseStatus(e, 404);
return;
}
if(session && session.user && session.user.id !== content.owner)
{ {
if(content.private) if(content.private)
{ {
@ -47,7 +50,7 @@ export default defineEventHandler(async (e) => {
content.content = convertFromStorableLinks(content.content); content.content = convertFromStorableLinks(content.content);
} }
return convertContentFromText(content.type, content.content); return content.content;
} }
setResponseStatus(e, 404); setResponseStatus(e, 404);

View File

@ -1,10 +1,10 @@
import { hasPermissions } from "#shared/auth.util"; import { hasPermissions } from "#shared/auth.util";
import useDatabase from '~/composables/useDatabase'; import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema'; import { explorerContentTable } from '~/db/schema';
import { project, type ProjectItem } from '~/schemas/project'; import { project } from '~/schemas/project';
import { parsePath } from "#shared/general.util"; import { parsePath } from "#shared/general.utils";
import { eq, getTableColumns, sql } from "drizzle-orm"; import { eq, getTableColumns, sql } from "drizzle-orm";
import type { ExploreContent, TreeItem } from "~/types/content"; import type { ExploreContent } from "~/types/content";
import type { TreeItemEditable } from "~/pages/explore/edit/index.vue"; import type { TreeItemEditable } from "~/pages/explore/edit/index.vue";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
@ -47,14 +47,14 @@ export default defineEventHandler(async (e) => {
const path = [item.parent, parsePath(item.name === '' ? item.title : item.name)].filter(e => !!e).join('/'); const path = [item.parent, parsePath(item.name === '' ? item.title : item.name)].filter(e => !!e).join('/');
tx.insert(explorerContentTable).values({ tx.insert(explorerContentTable).values({
path: item.path || path, path: item.path,
owner: user.id, owner: user.id,
title: item.title, title: item.title,
type: item.type, type: item.type,
navigable: item.navigable, navigable: item.navigable,
private: item.private, private: item.private,
order: item.order, order: item.order,
content: item.content ?? old?.content ?? null, content: item.content ?? old.content,
}).onConflictDoUpdate({ }).onConflictDoUpdate({
set: { set: {
path: path, path: path,
@ -64,12 +64,12 @@ export default defineEventHandler(async (e) => {
private: item.private, private: item.private,
order: item.order, order: item.order,
timestamp: new Date(), timestamp: new Date(),
content: item.content ?? old?.content ?? null, content: item.content ?? old.content,
}, },
target: explorerContentTable.path, target: explorerContentTable.path,
}).run(); }).run();
if(item.path !== path && !old) if(item.path !== path)
{ {
tx.update(explorerContentTable).set({ content: sql`replace(${explorerContentTable.content}, ${sql.placeholder('old')}, ${sql.placeholder('new')})` }).prepare().run({ 'old': item.path, 'new': path }); tx.update(explorerContentTable).set({ content: sql`replace(${explorerContentTable.content}, ${sql.placeholder('old')}, ${sql.placeholder('new')})` }).prepare().run({ 'old': item.path, 'new': path });
} }

View File

@ -17,7 +17,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import Bun from 'bun'; import Bun from 'bun';
import { format } from '~/shared/general.util'; import { format } from '~/shared/general.utils';
const { id, userId, username, timestamp } = defineProps<{ const { id, userId, username, timestamp } = defineProps<{
id: number id: number

View File

@ -1,90 +0,0 @@
import type { CanvasNode } from "~/types/canvas";
import { clamp } from "#shared/general.util";
export type Direction = 'bottom' | 'top' | 'left' | 'right';
export type Position = { x: number, y: number };
export type Box = Position & { w: number, h: number };
export type Path = {
path: string;
from: Position;
to: Position;
side: Direction;
}
export function edgePos(side: Direction, pos: Position, offset: number): Position {
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
}
}
}
export function getNode(nodes: CanvasNode[], id: string): CanvasNode | undefined
{
return nodes.find(e => e.id === id);
}
export function posFromDir(e: { minX: number, minY: number, maxX: number, maxY: number }, t: Direction): Position {
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 };
}
}
export 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 };
}
export function getPath(from: CanvasNode, fromSide: Direction, to: CanvasNode, toSide: Direction): Path | undefined {
if(from === undefined || to === undefined)
return;
const start = posFromDir(getBbox(from), fromSide), end = posFromDir(getBbox(to), toSide);
return bezier(start, fromSide, end, toSide);
}
export function bezier(from: Position, fromSide: Direction, to: Position, toSide: Direction): Path {
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,
};
}
export function labelCenter(from: CanvasNode, fromSide: Direction, to: CanvasNode, toSide: Direction): 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)`;
}
export function getCenter(n: Position, i: Position, r: Position, o: Position, e: number): Position {
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
};
}
export function gridSnap(value: number, grid: number): number
{
return Math.round(value / grid) * grid;
}

View File

@ -1,13 +1,5 @@
import type { CanvasContent } from '~/types/canvas'; import type { FileType } from '~/types/content';
import type { ContentMap, FileType } from '~/types/content';
export const DEFAULT_CONTENT: Record<FileType, ContentMap[FileType]['content']> = {
map: {},
canvas: { nodes: [], edges: []},
markdown: '',
file: '',
folder: null,
}
export function unifySlug(slug: string | string[]): string export function unifySlug(slug: string | string[]): string
{ {
return (Array.isArray(slug) ? slug.join('/') : slug); return (Array.isArray(slug) ? slug.join('/') : slug);
@ -47,47 +39,17 @@ export function format(date: Date, template: string): string
return template; return template;
} }
export function clamp(x: number, min: number, max: number): number export function clamp(x: number, min: number, max: number): number {
{ if (x > max)
return x > max ? max : x < min ? min : x; return max;
} if (x < min)
export function lerp(x: number, a: number, b: number): number return min;
{ return x;
return (1-x)*a+x*b;
}
export function convertContentFromText(type: FileType, content: string): CanvasContent | string {
switch(type)
{
case 'canvas':
return JSON.parse(content) as CanvasContent;
case 'map':
case 'file':
case 'folder':
case 'markdown':
return content;
default:
return content;
}
}
export function convertContentToText(type: FileType, content: any): string {
switch(type)
{
case 'canvas':
return JSON.stringify(content);
case 'map':
case 'file':
case 'folder':
case 'markdown':
return content;
default:
return content;
}
} }
export const iconByType: Record<FileType, string> = { export const iconByType: Record<FileType, string> = {
'folder': 'lucide:folder', 'folder': 'lucide:folder',
'canvas': 'ph:graph-light', 'canvas': 'ph:graph-light',
'file': 'radix-icons:file', 'file': 'radix-icons:file',
'markdown': 'radix-icons:file-text', 'markdown': 'radix-icons:file',
'map': 'lucide:map',
} }

6
types/canvas.d.ts vendored
View File

@ -1,7 +1,7 @@
export interface CanvasContent { export interface CanvasContent {
nodes?: CanvasNode[]; nodes: CanvasNode[];
edges?: CanvasEdge[]; edges: CanvasEdge[];
groups?: CanvasGroup[]; groups: CanvasGroup[];
} }
export type CanvasColor = { export type CanvasColor = {
class?: string; class?: string;

67
types/content.d.ts vendored
View File

@ -1,52 +1,11 @@
import type { CanvasContent as Canvas } from "./canvas"; import { explorerContentTable } from "~/db/schema";
import type { MapContent as Map } from "./map"; import type { CanvasContent } from "./canvas";
export type FileType = typeof explorerContentTable.$inferSelect.type;
export type FileType = keyof ContentMap; export type Overview = Omit<typeof explorerContentTable.$inferSelect, 'content'>;
export interface Overview { export type Content = { content: string };
path: string; export type ExploreContent= Overview & Partial<Content>;
owner: number; export type TreeItem = ExploreContent & { children?: TreeItem[] };
title: string;
timestamp: Date;
navigable: boolean;
private: boolean;
order: number;
visit: number;
}
export interface CanvasContent extends Overview {
type: 'canvas';
content?: Canvas;
}
export interface MapContent extends Overview {
type: 'map';
content?: string;
}
export interface MarkdownContent extends Overview {
type: 'markdown';
content?: string;
}
export interface FileContent extends Overview {
type: 'file';
content?: string;
}
export interface FolderContent extends Overview {
type: 'folder';
content?: null;
}
export interface ContentMap
{
markdown: MarkdownContent;
file: FileContent;
canvas: CanvasContent;
map: MapContent;
folder: FolderContent;
}
export type ExploreContent = ContentMap[FileType];
export type TreeItem = ExploreContent & {
children?: TreeItem[];
}
export interface ContentComposable { export interface ContentComposable {
content: Ref<ExploreContent[]> content: Ref<ExploreContent[]>
@ -60,3 +19,15 @@ export interface ContentComposable {
*/ */
get: (path: string) => Promise<void> get: (path: string) => Promise<void>
} }
export type MarkdownContent = string;
export type FileContent = any;
export type FolderContent = null;
export interface ContentTypeMap
{
markdown: MarkdownContent;
canvas: CanvasContent;
file: FileContent;
folder: FolderContent;
}

15
types/general.d.ts vendored
View File

@ -1,15 +0,0 @@
export type Preferences = {
markdown: MarkdownPreferences,
canvas: CanvasPreferences,
} & GeneralPreferences;
type GeneralPreferences = {
};
type MarkdownPreferences = {
editing: 'split' |'reading' | 'editing';
};
type CanvasPreferences = {
snap: boolean;
size?: number;
};

17
types/map.d.ts vendored
View File

@ -1,17 +0,0 @@
export interface MapContent
{
bg: string;
interests: MapInterest[];
}
export interface MapInterest
{
id: string;
x: number;
y: number;
text: string;
width?: number;
height?: number;
color?: CanvasColor;
valign?: 'start' | 'center' | 'end';
halign?: 'start' | 'center' | 'end';
}