Compare commits
20 Commits
896af11fa7
...
af317cb0e3
| Author | SHA1 | Date |
|---|---|---|
|
|
af317cb0e3 | |
|
|
8fc1855ae6 | |
|
|
f3c453b1b2 | |
|
|
62b2f3bbfb | |
|
|
0b1809c3f6 | |
|
|
3f04bb3d0c | |
|
|
685bd47fc4 | |
|
|
f32c51ca38 | |
|
|
348c991c54 | |
|
|
76db788192 | |
|
|
4433cf0e00 | |
|
|
9439dd2d95 | |
|
|
823f3d7730 | |
|
|
62950be032 | |
|
|
b1a9eb859e | |
|
|
83ac9b1f36 | |
|
|
7403515f80 | |
|
|
3839b003dc | |
|
|
e7412f6768 | |
|
|
6f305397a8 |
|
|
@ -0,0 +1,636 @@
|
|||
<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>
|
||||
|
|
@ -84,13 +84,14 @@ watchEffect(() => {
|
|||
</script>
|
||||
|
||||
<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" />
|
||||
<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" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cm-editor
|
||||
{
|
||||
@apply bg-transparent;
|
||||
@apply flex-1;
|
||||
}
|
||||
.cm-editor .cm-content
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import type { Component } from 'vue';
|
||||
import { heading } from 'hast-util-heading';
|
||||
import { headingRank } from 'hast-util-heading-rank';
|
||||
import { parseId } from '~/shared/general.utils';
|
||||
import { parseId } from '~/shared/general.util';
|
||||
import type { Root } from 'hast';
|
||||
|
||||
const { content, proses, filter } = defineProps<{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<AvatarRoot class="inline-flex h-12 w-12 select-none items-center justify-center overflow-hidden align-middle">
|
||||
<AvatarRoot class="inline-flex select-none items-center justify-center overflow-hidden align-middle" :class="SIZES[size]">
|
||||
<AvatarImage class="h-full w-full object-cover" :src="src" asChild @loading-status-change="(status) => loading = status === 'loading'">
|
||||
<img :src="src" />
|
||||
</AvatarImage>
|
||||
|
|
@ -13,10 +13,18 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
const { src, icon, text } = defineProps<{
|
||||
const { src, icon, text, size = 'medium' } = defineProps<{
|
||||
src: string
|
||||
icon?: string
|
||||
text?: string
|
||||
size?: keyof typeof SIZES
|
||||
}>();
|
||||
const loading = ref(true);
|
||||
</script>
|
||||
<script lang="ts">
|
||||
const SIZES = {
|
||||
'small': 'h-6',
|
||||
'medium': 'h-10',
|
||||
'large': 'h-16',
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<TreeRoot v-bind="forward" v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 overflow-auto max-h-full">
|
||||
<DraggableTreeItem v-for="item in flattenItems" :key="item._id" v-bind="item" class="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>
|
||||
<DraggableTreeItem v-for="item in flattenItems" :key="item._id" v-bind="item" class="group flex items-center outline-none relative cursor-pointer max-w-full" @select.prevent @toggle.prevent>
|
||||
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver }">
|
||||
<slot :handleToggle="handleToggle"
|
||||
:handleSelect="handleSelect"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<template v-for="(item, idx) of options">
|
||||
<template v-if="item.type === 'item'">
|
||||
<DropdownMenuItem :disabled="item.disabled" :textValue="item.label" @select="item.select" :class="{'!pe-1': item.kbd}" class="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">
|
||||
<DropdownMenuItem :disabled="item.disabled" :textValue="item.label" @select="item.select" :class="{'!pe-1': item.kbd}" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="absolute left-1.5" />
|
||||
<div class="flex flex-1 justify-between">
|
||||
<span>{{ item.label }}</span>
|
||||
|
|
@ -10,14 +10,17 @@
|
|||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<!-- TODO -->
|
||||
<template v-else-if="item.type === 'checkbox'">
|
||||
<DropdownMenuCheckboxItem :disabled="item.disabled" :textValue="item.label" @update:checked="item.select">
|
||||
<DropdownMenuItemIndicator>
|
||||
<Icon icon="radix-icons:check" />
|
||||
</DropdownMenuItemIndicator>
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="item.kbd"> {{ item.kbd }} </span>
|
||||
<DropdownMenuCheckboxItem :disabled="item.disabled" :textValue="item.label" v-model:checked="item.checked" @update:checked="item.select" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
|
||||
<span class="w-6 flex items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<Icon icon="radix-icons:check" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<div class="flex flex-1 justify-between">
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="item.kbd" class="mx-2 text-xs font-mono text-light-70 dark:text-dark-70 relative top-0.5"> {{ item.kbd }} </span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<slot></slot>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal v-if="!disabled">
|
||||
<HoverCardContent :class="$attrs.class" :side="side" class="data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 p-5 data-[state=open]:transition-all text-light-100 dark:text-dark-100" >
|
||||
<HoverCardContent :class="$attrs.class" :side="side" :align="align" avoidCollisions :collisionPadding="20" class="max-h-[var(--radix-hover-card-content-available-height)] data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 p-5 data-[state=open]:transition-all text-light-100 dark:text-dark-100" >
|
||||
<slot name="content"></slot>
|
||||
<HoverCardArrow class="fill-light-35 dark:fill-dark-35" />
|
||||
</HoverCardContent>
|
||||
|
|
@ -13,11 +13,24 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { delay = 500, disabled = false, side = 'bottom' } = defineProps<{
|
||||
const { delay = 500, disabled = false, side = 'bottom', align = 'center', triggerKey } = defineProps<{
|
||||
delay?: number
|
||||
disabled?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
triggerKey?: string
|
||||
}>();
|
||||
|
||||
const emits = defineEmits(['open'])
|
||||
const 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>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<span class="rounded bg-light-35 dark:bg-dark-35 font-mono text-sm px-1 py-0 select-none" style="box-shadow: black 0 2px 0 1px;"><slot /></span>
|
||||
</template>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
<SelectPortal :disabled="disabled">
|
||||
<SelectContent :position="position"
|
||||
class="min-w-[160px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-40">
|
||||
class="min-w-[160px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-50">
|
||||
<SelectScrollUpButton>
|
||||
<Icon icon="radix-icons:chevron-up" />
|
||||
</SelectScrollUpButton>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm" :items="model" :get-key="getKey" :defaultExpanded="flatten(model)">
|
||||
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level - 0.5}em` }" v-bind="item.bind" class="flex items-center px-2 outline-none relative cursor-pointer">
|
||||
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 text-sm" :items="model" :get-key="getKey" :defaultExpanded="flatten(model)">
|
||||
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level / 2 - 0.5}em` }" v-bind="item.bind" class="flex items-center ps-2 outline-none relative cursor-pointer">
|
||||
<slot :isExpanded="isExpanded" :item="item" />
|
||||
</TreeItem>
|
||||
</TreeRoot>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import type { Direction, Path } from "~/shared/canvas.util";
|
||||
import type { CanvasColor } from "~/types/canvas";
|
||||
|
||||
type Direction = 'bottom' | 'top' | 'left' | 'right';
|
||||
|
||||
const props = defineProps<{
|
||||
path: {
|
||||
path: string;
|
||||
from: { x: number; y: number };
|
||||
to: { x: number; y: number };
|
||||
side: Direction;
|
||||
};
|
||||
path: Path;
|
||||
color?: CanvasColor;
|
||||
label?: string;
|
||||
}>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
<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>
|
||||
|
|
@ -11,19 +11,23 @@
|
|||
</template>
|
||||
<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"
|
||||
: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" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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<{
|
||||
path: string
|
||||
}>();
|
||||
|
||||
const { content, get } = useContent();
|
||||
const overview = computed(() => content.value.find(e => e.path === path));
|
||||
const overview = computed(() => content.value.find(e => e.path === path) as CanvasContent | undefined);
|
||||
|
||||
const loading = ref(false);
|
||||
if(overview.value && !overview.value.content)
|
||||
|
|
@ -32,90 +36,6 @@ if(overview.value && !overview.value.content)
|
|||
await get(path);
|
||||
loading.value = false;
|
||||
}
|
||||
const canvas = computed(() => overview.value && overview.value.content ? JSON.parse(overview.value.content) as CanvasContent : undefined);
|
||||
const canvas = computed(() => overview.value && overview.value.content ? overview.value.content : undefined);
|
||||
|
||||
</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>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { useDrag, useHover, usePinch, useWheel } from '@vueuse/gesture';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import { clamp } from '#shared/general.utils';
|
||||
import { clamp } from '#shared/general.util';
|
||||
|
||||
const { path } = defineProps<{ path: string }>();
|
||||
const { user } = useUserSession();
|
||||
|
|
@ -70,6 +70,18 @@ dark:border-dark-yellow
|
|||
dark:border-dark-green
|
||||
dark:border-dark-cyan
|
||||
dark:border-dark-purple
|
||||
outline-light-red
|
||||
outline-light-orange
|
||||
outline-light-yellow
|
||||
outline-light-green
|
||||
outline-light-cyan
|
||||
outline-light-purple
|
||||
dark:outline-dark-red
|
||||
dark:outline-dark-orange
|
||||
dark:outline-dark-yellow
|
||||
dark:outline-dark-green
|
||||
dark:outline-dark-cyan
|
||||
dark:outline-dark-purple
|
||||
|
||||
*/
|
||||
|
||||
|
|
@ -141,7 +153,13 @@ const pinchHandler = usePinch(({ offset: [z] }: { offset: number[] }) => {
|
|||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{transform: `scale(${zoom}) translate(${dispX}px, ${dispY}px)`}" >
|
||||
<div :style="{
|
||||
'--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" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ if(overview.value && !overview.value.content)
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 justify-start items-start flex-col xl:px-24 md:px-8 px-4 py-6">
|
||||
<div class="flex flex-1 justify-start items-start flex-col lg:px-16 xl:px-32 2xl:px-64 py-6">
|
||||
<Loading v-if="loading" />
|
||||
<template v-else-if="overview">
|
||||
<div v-if="!popover" class="flex flex-1 flex-row justify-between items-center">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,27 @@
|
|||
<template>
|
||||
<span class="text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85"><slot v-bind="$attrs"></slot></span>
|
||||
</template>
|
||||
<span>
|
||||
<HoverCard trigger-key="Ctrl" nuxt-client class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview?.type === 'canvas'}" :disabled="!overview">
|
||||
<template #content>
|
||||
<Markdown v-if="overview?.type === 'markdown'" class="!px-6" :path="pathname" :filter="hash.substring(1)" popover />
|
||||
<template v-else-if="overview?.type === 'canvas'"><div class="w-[600px] h-[600px] relative"><Canvas :path="pathname" /></div></template>
|
||||
</template>
|
||||
<span>
|
||||
<span class="text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85"><slot v-bind="$attrs"></slot></span>
|
||||
</span>
|
||||
</HoverCard>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseURL } from 'ufo';
|
||||
|
||||
const { href } = defineProps<{
|
||||
href: string
|
||||
class?: string
|
||||
}>();
|
||||
|
||||
const { hash, pathname } = parseURL(href);
|
||||
|
||||
const { content } = useContent();
|
||||
const overview = computed(() => content.value.find(e => e.path === pathname));
|
||||
</script>
|
||||
|
|
@ -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">
|
||||
<HoverCard nuxt-client class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview?.type === 'canvas'}" :disabled="!overview">
|
||||
<template #content>
|
||||
<Markdown v-if="overview?.type === 'markdown'" class="!px-10" :path="pathname" :filter="hash.substring(1)" popover />
|
||||
<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>
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
<script setup lang="ts">
|
||||
import { parseURL } from 'ufo';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import { iconByType } from '#shared/general.utils';
|
||||
import { iconByType } from '#shared/general.util';
|
||||
|
||||
const { href } = defineProps<{
|
||||
href: string
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<CollapsibleTrigger>
|
||||
<div :class="{ 'cursor-pointer': fold !== undefined }" class="flex flex-row items-center justify-start ps-2">
|
||||
<Icon :icon="calloutIconByType[type] ?? defaultCalloutIcon" class="w-6 h-6 stroke-2 float-start me-2" />
|
||||
<span v-if="title" class="block font-bold">{{ title }}</span>
|
||||
<Icon :icon="calloutIconByType[type] ?? defaultCalloutIcon" inline class="w-6 h-6 stroke-2 float-start me-2 flex-shrink-0" />
|
||||
<span v-if="title" class="block font-bold text-start">{{ title }}</span>
|
||||
<Icon icon="radix-icons:caret-right" v-if="fold !== undefined" class="transition-transform group-data-[state=open]:rotate-90 w-6 h-6 mx-6" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
import { parseId } from '#shared/general.util';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
import { parseId } from '#shared/general.util';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
import { parseId } from '#shared/general.util';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
import { parseId } from '#shared/general.util';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
import { parseId } from '#shared/general.util';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
import { parseId } from '#shared/general.util';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import RemarkParse from "remark-parse";
|
|||
import RemarkRehype from 'remark-rehype';
|
||||
import RemarkOfm from 'remark-ofm';
|
||||
import RemarkGfm from 'remark-gfm';
|
||||
import RemarkBreaks from 'remark-breaks';
|
||||
import RemarkFrontmatter from 'remark-frontmatter';
|
||||
|
||||
export default function useMarkdown(): (md: string) => Root
|
||||
|
|
@ -14,7 +15,7 @@ export default function useMarkdown(): (md: string) => Root
|
|||
const parse = (markdown: string) => {
|
||||
if (!processor)
|
||||
{
|
||||
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkFrontmatter]);
|
||||
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]);
|
||||
processor.use(RemarkRehype);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const useContentFetch = (force: boolean) => useContent().fetch(force);
|
|||
|
||||
/**
|
||||
* Composable to get back the user session and utils around it.
|
||||
* @see https://github.com/atinux/nuxt-auth-utils
|
||||
*/
|
||||
export function useUserSession(): UserSessionComposable {
|
||||
const sessionState = useSessionState()
|
||||
|
|
|
|||
BIN
db.sqlite-shm
BIN
db.sqlite-shm
Binary file not shown.
BIN
db.sqlite-wal
BIN
db.sqlite-wal
Binary file not shown.
|
|
@ -39,7 +39,7 @@ export const explorerContentTable = sqliteTable("explorer_content", {
|
|||
path: text().primaryKey(),
|
||||
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
title: text().notNull(),
|
||||
type: text({ enum: ['file', 'folder', 'markdown', 'canvas'] }).notNull(),
|
||||
type: text({ enum: ['file', 'folder', 'markdown', 'canvas', 'map'] }).notNull(),
|
||||
content: blob({ mode: 'buffer' }),
|
||||
navigable: int({ mode: 'boolean' }).notNull().default(true),
|
||||
private: int({ mode: 'boolean' }).notNull().default(false),
|
||||
|
|
|
|||
|
|
@ -1,84 +1,59 @@
|
|||
<template>
|
||||
<CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open">
|
||||
<div class="z-50 md:hidden flex w-full items-center justify-between h-12 border-b border-light-35 dark:border-dark-35">
|
||||
<div class="flex items-center px-2">
|
||||
<div class="z-50 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
|
||||
<div class="flex items-center px-2 gap-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button icon class="ms-2 !bg-transparent group">
|
||||
<Button icon class="!bg-transparent group md:hidden">
|
||||
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" />
|
||||
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<NuxtLink class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-md:ps-6" aria-label="Accueil" :to="{ path: '/', force: true }">Accueil</NuxtLink>
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-opacity-70 m-2 flex items-center gap-4" aria-label="Accueil" :to="{ path: '/', force: true }">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
||||
<Avatar src="/logo.light.svg" class="block dark:hidden" />
|
||||
<span class="text-xl max-md:hidden">d[any]</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex items-center px-2">
|
||||
<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>
|
||||
</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>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
<div class="flex items-center gap-8 max-md:hidden">
|
||||
<Tooltip message="Developpement en cours" side="bottom"><NuxtLink href="#" class="text-light-70 dark:text-dark-70">Parcourir les projets</NuxtLink></Tooltip>
|
||||
<Tooltip message="Developpement en cours" side="bottom"><NuxtLink href="#" class="text-light-70 dark:text-dark-70">Créer du contenu</NuxtLink></Tooltip>
|
||||
</div>
|
||||
<div class="flex items-center px-2 gap-4">
|
||||
<template v-if="!loggedIn">
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">Se connecter</NuxtLink>
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70 max-md:hidden" :to="{ name: 'user-register' }">Créer un compte</NuxtLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-row relative h-screen overflow-hidden">
|
||||
<CollapsibleContent asChild forceMount>
|
||||
<div class="bg-light-0 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 flex-col gap-4 xl:px-6 px-3 py-4">
|
||||
<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">
|
||||
<div class="bg-light-0 dark:bg-dark-0 z-40 w-screen md:w-[18rem] border-r border-light-30 dark:border-dark-30 flex flex-col justify-between my-2 max-md:data-[state=closed]:hidden">
|
||||
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex flex-row flex-1 justify-between items-center py-4 px-2">
|
||||
<NuxtLink :href="{ name: 'explore-path', params: { path: 'index' } }" class="flex flex-1 font-bold text-lg items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" active-class="text-accent-blue border-s-2 !border-accent-blue">
|
||||
<span class="pl-3 py-1 flex-1 truncate">Projet</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="user && hasPermissions(user.permissions, ['admin', 'editor'])" :to="{ name: 'explore-edit' }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink>
|
||||
</div>
|
||||
<Tree v-if="pages" v-model="pages" :getKey="(item) => item.path">
|
||||
<Tree v-if="pages" v-model="pages" :getKey="(item) => item.path" class="ps-4">
|
||||
<template #default="{ item, isExpanded }">
|
||||
<NuxtLink :href="item.value.path && !item.hasChildren ? { name: 'explore-path', params: { path: item.value.path } } : undefined" class="flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple" :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 - 1}em` }" />
|
||||
<NuxtLink :href="item.value.path && !item.hasChildren ? { name: 'explore-path', params: { path: item.value.path } } : undefined" class="flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full" :class="{ 'font-medium': item.hasChildren }" active-class="text-accent-blue" :data-private="item.value.private">
|
||||
<Icon v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level / 2 - 1.5}em` }" />
|
||||
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="w-5 h-5" />
|
||||
<div class="pl-3 py-1 flex-1 truncate">
|
||||
<div class="pl-1.5 py-1.5 flex-1 truncate">
|
||||
{{ item.value.title }}
|
||||
</div>
|
||||
<Tooltip message="Privé" side="right"><Icon v-show="item.value.private" icon="radix-icons:lock-closed" /></Tooltip>
|
||||
<Tooltip message="Privé" side="right"><Icon v-show="item.value.private" class="mx-1" icon="radix-icons:lock-closed" /></Tooltip>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</Tree>
|
||||
</div>
|
||||
<div class="xl:px-12 px-6 py-4 text-center text-xs text-light-60 dark:text-dark-60">
|
||||
<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 - 2024</p>
|
||||
<p>Copyright Peaceultime - 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
|
@ -89,7 +64,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import { iconByType } from '#shared/general.utils';
|
||||
import { iconByType } from '#shared/general.util';
|
||||
import type { DropdownOption } from '~/components/base/DropdownMenu.vue';
|
||||
import { hasPermissions } from '~/shared/auth.util';
|
||||
import type { TreeItem } from '~/types/content';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDjNZPI8RGt6fVV
|
||||
e0403ySKRX1Zh4lPYucvxsojrG86ZS/gm+zHFbTf8kwBR5CUWLuqkNo3vql6W7Go
|
||||
rbPLvbGs1uultilwMRxp0RHx23zecKQMdKA5GiLW+9AI8O23RqNWyF9nJAPdq7TV
|
||||
Dux8OpJXPuT6SWGLBaXcagbe8H/cVMsTqx8FGoOxh9A+MIV6bNaaxvpSR82H9s7i
|
||||
nRSJVxxwHYigrGO5iWvehbjzX0zCD3hzQfZpWWrKa8v8p8+3jkE2dr6l5h1T6Qmi
|
||||
7ZlINiY4vyxgAUM4L9fwSoStWKLf8SnqYOlLXTm7bpBbu5oOQ8yKtJXyat0xx11B
|
||||
FqkqeJmFAgMBAAECggEAcX7U6L5K54YD0AR9J3oDxbI6kFtc4rPz6fCyDqnXEeNz
|
||||
zA33c+dK58cf4k++T+wXKnebGdd6zy04jJrgQjjqpPziz280Od++YrlV7muGb5Ly
|
||||
z2n+kyeUGbHF1IGNLUzy0Kncxie+ap+YAAmpZdDYQw6e0MuRFyHmHTk1X23hYMxl
|
||||
hc8AH5+l+FW0RfgGR8tUFTVc6KbojnKWq2G946NFxHoRwy2/2xEnZu5nciIeUY4O
|
||||
2McnVDlLcomMTt6ScJjZo+fnTyKsWX4yrk3nVPPm7h9Oh4i4QB3/OEqKnlsUCS3u
|
||||
fD3UWlamTF7CETUpuGGj0UaIGFwi3X3SjbuQPZGYzQKBgQDwKmFlL62GyMXsEnI4
|
||||
AVHdnRRCUEgJbX/JVftYdn6psPiCZz+ypr6UKBiyQH0QtxUHxqD2iT2nDR5RmZZR
|
||||
cHhBiJ0KBE3JS3lCm+QcW9r4FOb+V91CycHl4FbnR7LGzJ4ScG0t9F/bJdbyuuiO
|
||||
nwN+sjoNQ55jckaWN5H3kgh8jwKBgQDyMIPuENPUoQksN9ijWkRJPg9qOSF72kEu
|
||||
Ro3wvNdLqC3J3k+Z9Y++diPYOI16nMj/5aTOlWptcr1tzy/rBxXrL1/8uPoGuWGJ
|
||||
OxDrc2lr0rwP6yp8bsmJkhGa1zv5pfisP6L6l/kaRwJ4oe7aUEQUXLndR4D/BIYe
|
||||
PYcOOJs6qwKBgHhUg5/zF3pkteXmCBxPbPkgbrobBzzSBCiYT+qu1B+pb5nGqX+V
|
||||
U/9fZ6BH92GcmYjf2F4tvRop1HsF/O6o71fGXwhZx6+HhSX+fXhH/Zo2vtXIqC+C
|
||||
bwgCMwiGP+ijNMAAXHOd8TkX6G6Nf1+WBGZCXhuvOXiSFRPGm/fyzxW5AoGAQJXp
|
||||
iOIZ63kqXg1ii2V2EmYnbDdiE4pHmZSdI5bofzeRRmUvqyoONEeDFZU3PXx0KbHO
|
||||
+nxkDl3r4E3BRJb2JGrU2StnGcX0GcmToIZ9lZB0MHaRNO/CdRpr8XP2fYPiReUO
|
||||
jG9cscJACXV9oeCH1zpHIph/8QH+1i+oRYWY99MCgYBIMjO4P1t59yCR+hVAs6vB
|
||||
AvY9hcjsrsqqCjuk10BAknGf7sXVcJKXh6ZwOZTq+s3f+jvdCILqomjnTETtvqi3
|
||||
o+lxM5BsI3kih1ZZwmp6l5OZ+XoOHC2enJq6+yvar2cQQ3JXHqgaOeGqvPp79Qgi
|
||||
lUhewf7i9ea3HhsAJVn5zQ==
|
||||
-----END PRIVATE KEY-----
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIEPTCCAqWgAwIBAgIRAOY00hX9DwO86FISPVYlPOEwDQYJKoZIhvcNAQELBQAw
|
||||
czEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSQwIgYDVQQLDBtQQy1D
|
||||
TEVNRU5UXFBlYWNlQFBDLUNsZW1lbnQxKzApBgNVBAMMIm1rY2VydCBQQy1DTEVN
|
||||
RU5UXFBlYWNlQFBDLUNsZW1lbnQwHhcNMjUwMTA4MjAzMzU2WhcNMjcwNDA4MTkz
|
||||
MzU2WjBPMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUx
|
||||
JDAiBgNVBAsMG1BDLUNMRU1FTlRcUGVhY2VAUEMtQ2xlbWVudDCCASIwDQYJKoZI
|
||||
hvcNAQEBBQADggEPADCCAQoCggEBAOM1k8jxEa3p9VV7TjTfJIpFfVmHiU9i5y/G
|
||||
yiOsbzplL+Cb7McVtN/yTAFHkJRYu6qQ2je+qXpbsaits8u9sazW66W2KXAxHGnR
|
||||
EfHbfN5wpAx0oDkaItb70Ajw7bdGo1bIX2ckA92rtNUO7Hw6klc+5PpJYYsFpdxq
|
||||
Bt7wf9xUyxOrHwUag7GH0D4whXps1prG+lJHzYf2zuKdFIlXHHAdiKCsY7mJa96F
|
||||
uPNfTMIPeHNB9mlZaspry/ynz7eOQTZ2vqXmHVPpCaLtmUg2Jji/LGABQzgv1/BK
|
||||
hK1Yot/xKepg6UtdObtukFu7mg5DzIq0lfJq3THHXUEWqSp4mYUCAwEAAaNwMG4w
|
||||
DgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaA
|
||||
FDPM3O7GEA4DgJchIK0hiZtf97UjMCYGA1UdEQQfMB2CCWxvY2FsaG9zdIcQAAAA
|
||||
AAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsFAAOCAYEAWLbhajkW3jpXKBNnE4dp
|
||||
fCD1uJ/G8Cuy1poNsXIp2mlhDu4b1mC8mMPwhd01OEXbxZnzLdFiYYy5evxkCODX
|
||||
TlohrWObgCs4iRtSpFT2QOkqLfohdNBtKN6fK2XGbxTqLfW5VStRH2//MzL0P+Cm
|
||||
tUI8P0Tt3Y5jAxrTqmXptlsKkgyhhNUHlXfJCxhvlfvcTvagmCMjf6xBF5ExRH/n
|
||||
GRiWbqSpKQV2PpJObWC8asMJebjkLHQos0v7EobfgbUVVlQRksvlu4EjRZZO3GVD
|
||||
d0+4oUVkG1MHAixNgxvoKrIA2RSYq4D/VBTKvE727SeqySAC4eAaGeD74yG9Tuzr
|
||||
lTBEauqDRlyJX4sS2D1dub655FScNQCdxiB0v+nNuBaJubrGWtXbiBsXYlbHl2cL
|
||||
Nq8rZAobhB0o4DHUIOsY0ygFxqZrZ+3po5gyEb1rbcejTzUoyrh+PCCC6vxbfkOR
|
||||
Db1NyZTKXtVrbOYn6mJ6tsJC2oI+ngciN1mo0eg/ULxB
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
import vuePlugin from 'rollup-plugin-vue'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2024-04-03',
|
||||
|
|
@ -168,5 +170,11 @@ 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'),
|
||||
}
|
||||
}
|
||||
})
|
||||
29
package.json
29
package.json
|
|
@ -4,51 +4,52 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "bun i",
|
||||
"dev": "bunx --bun nuxi dev"
|
||||
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 bunx --bun nuxi dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@nuxtjs/sitemap": "^7.0.1",
|
||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||
"@nuxtjs/sitemap": "^7.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.13.1",
|
||||
"@vueuse/gesture": "^2.0.0",
|
||||
"@vueuse/math": "^11.3.0",
|
||||
"@vueuse/nuxt": "^11.3.0",
|
||||
"@vueuse/math": "^12.5.0",
|
||||
"@vueuse/nuxt": "^12.5.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"drizzle-orm": "^0.35.3",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"hast": "^1.0.0",
|
||||
"hast-util-heading": "^3.0.0",
|
||||
"hast-util-heading-rank": "^3.0.0",
|
||||
"lodash.capitalize": "^4.2.1",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nuxt": "^3.15.0",
|
||||
"nodemailer": "^6.10.0",
|
||||
"nuxt": "3.15.1",
|
||||
"nuxt-security": "^2.1.5",
|
||||
"radix-vue": "^1.9.12",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-ofm": "link:remark-ofm",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"rollup-plugin-postcss": "^4.0.2",
|
||||
"rollup-plugin-vue": "^6.0.0",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.14",
|
||||
"@types/bun": "^1.2.0",
|
||||
"@types/lodash.capitalize": "^4.2.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/unist": "^3.0.3",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"bun-types": "^1.1.42",
|
||||
"drizzle-kit": "^0.26.2",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"bun-types": "^1.2.0",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"rehype-stringify": "^10.0.1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { format, iconByType } from '~/shared/general.utils';
|
||||
import { format, iconByType } from '~/shared/general.util';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
|
||||
interface File
|
||||
|
|
|
|||
|
|
@ -5,94 +5,74 @@
|
|||
<ClientOnly>
|
||||
<CollapsibleRoot asChild class="flex flex-1 flex-col xl:-mx-12 xl:-my-8 lg:-mx-8 lg:-my-6 -mx-6 -my-3 overflow-hidden" v-model="open">
|
||||
<div>
|
||||
<div class="z-50 md:hidden flex w-full items-center justify-between h-12 border-b border-light-35 dark:border-dark-35">
|
||||
<div class="flex items-center px-2">
|
||||
<div class="z-50 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
|
||||
<div class="flex items-center px-2 gap-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button icon class="ms-2 !bg-transparent group">
|
||||
<Button icon class="!bg-transparent group md:hidden">
|
||||
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" />
|
||||
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-opacity-70 m-2 flex items-center gap-4" aria-label="Accueil" :to="{ path: '/', force: true }">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
||||
<Avatar src="/logo.light.svg" class="block dark:hidden" />
|
||||
<span class="text-xl max-md:hidden">d[any]</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex items-center px-2">
|
||||
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
|
||||
<div class="flex items-center px-2 gap-4">
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-row relative overflow-hidden">
|
||||
<CollapsibleContent asChild forceMount>
|
||||
<div class=" 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 flex-col gap-4 xl:px-6 px-3 py-4">
|
||||
<div class="flex justify-between items-center max-md:hidden">
|
||||
<div class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-md:ps-6" aria-label="Accueil">
|
||||
<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 class="bg-light-0 dark:bg-dark-0 z-40 w-screen md:w-[18rem] border-r border-light-30 dark:border-dark-30 flex flex-col justify-between my-2 max-md:data-[state=closed]:hidden">
|
||||
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex flex-row justify-between items-center pt-2 pb-4 mb-2 px-2 gap-4 border-b border-light-35 dark:border-dark-35">
|
||||
<Button @click="router.push({ name: 'explore-path', params: { path: selected ? getPath(selected) : 'index' } })">Quitter</Button>
|
||||
<Button @click="save(true);">Enregistrer</Button>
|
||||
<Tooltip side="top" message="Nouveau">
|
||||
<DropdownMenu align="end" side="bottom" :options="[{
|
||||
type: 'item',
|
||||
label: 'Markdown',
|
||||
kbd: 'Ctrl+N',
|
||||
icon: 'radix-icons:file-text',
|
||||
select: () => add('markdown'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Dossier',
|
||||
kbd: 'Ctrl+Shift+N',
|
||||
icon: 'lucide:folder',
|
||||
select: () => add('folder'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Canvas',
|
||||
icon: 'ph:graph-light',
|
||||
select: () => add('canvas'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Carte',
|
||||
icon: 'lucide:map',
|
||||
select: () => add('map'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Fichier',
|
||||
icon: 'radix-icons:file',
|
||||
select: () => add('file'),
|
||||
}]">
|
||||
<Button icon><Icon class="w-5 h-5" icon="radix-icons:plus" /></Button>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</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">
|
||||
<DropdownMenu align="center" side="bottom" :options="[{
|
||||
type: 'item',
|
||||
label: 'Markdown',
|
||||
kbd: 'Ctrl+N',
|
||||
icon: 'radix-icons:file',
|
||||
select: () => add('markdown'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Dossier',
|
||||
kbd: 'Ctrl+Shift+N',
|
||||
icon: 'lucide:folder',
|
||||
select: () => add('folder'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Canvas',
|
||||
icon: 'ph:graph-light',
|
||||
select: () => add('canvas'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Fichier',
|
||||
icon: 'radix-icons:file-text',
|
||||
select: () => add('file'),
|
||||
}]">
|
||||
<Button icon><Icon class="w-5 h-5" icon="radix-icons:plus" /></Button>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</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 }}
|
||||
<DraggableTree class="ps-4 text-sm" :items="navigation ?? undefined" :get-key="(item: Partial<TreeItemEditable>) => item.path !== undefined ? getPath(item as TreeItemEditable) : ''" @updateTree="drop"
|
||||
v-model="selected" :defaultExpanded="defaultExpanded" :get-children="(item: Partial<TreeItemEditable>) => item.type === 'folder' ? item.children : undefined" >
|
||||
<template #default="{ handleToggle, handleSelect, isExpanded, isDragging, item }">
|
||||
<div class="flex flex-1 items-center overflow-hidden" :class="{ 'opacity-50': isDragging }" :style="{ 'padding-left': `${item.level / 2 - 0.5}em` }">
|
||||
<div class="flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple group-data-[selected]:text-accent-blue">
|
||||
<Icon @click="handleToggle" v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level / 2 - 1.5}em` }" />
|
||||
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="w-5 h-5" @click="handleSelect" />
|
||||
<div class="pl-1.5 py-1.5 flex-1 truncate" :title="item.value.title" @click="handleSelect" :class="{ 'font-semibold': item.hasChildren }">
|
||||
{{ item.value.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span @click="item.value.private = !item.value.private">
|
||||
|
|
@ -108,7 +88,7 @@
|
|||
</template>
|
||||
<template #hint="{ instruction }">
|
||||
<div v-if="instruction" class="absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50" :style="{
|
||||
width: `calc(100% - ${instruction.currentLevel - 1}em)`
|
||||
width: `calc(100% - ${instruction.currentLevel / 2 - 1.5}em)`
|
||||
}" :class="{
|
||||
'!border-b-4': instruction?.type === 'reorder-below',
|
||||
'!border-t-4': instruction?.type === 'reorder-above',
|
||||
|
|
@ -117,50 +97,110 @@
|
|||
</template>
|
||||
</DraggableTree>
|
||||
</div>
|
||||
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60">
|
||||
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
||||
<p>Copyright Peaceultime - 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
<div class="flex flex-1 flex-row max-h-full overflow-hidden">
|
||||
<div v-if="selected" class="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">
|
||||
<div v-if="selected" class="flex flex-1 flex-col items-start justify-start max-h-full relative">
|
||||
<Head>
|
||||
<Title>d[any] - Modification de {{ selected.title }}</Title>
|
||||
</Head>
|
||||
<div>
|
||||
<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" />
|
||||
<div class="flex flex-col justify-start items-start">
|
||||
<Switch label="Chemin personnalisé" v-model="selected.customPath" />
|
||||
<span>
|
||||
<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) => {
|
||||
if(selected && selected.customPath)
|
||||
<CollapsibleRoot v-model:open="topOpen" class="group data-[state=open]:mt-4 w-full relative">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button class="absolute left-1/2 -translate-x-1/2 group-data-[state=open]:-bottom-3 group-data-[state=closed]:-bottom-6 z-30" icon>
|
||||
<Icon v-if="topOpen" icon="radix-icons:caret-up" class="h-4 w-4" />
|
||||
<Icon v-else icon="radix-icons:caret-down" class="h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="xl:px-12 lg:px-8 px-6">
|
||||
<div class="pb-2 grid lg:grid-cols-2 grid-cols-1 lg:items-center justify-between gap-x-4 flex-1 border-b border-light-35 dark:border-dark-35">
|
||||
<input type="text" v-model="selected.title" @input="() => {
|
||||
if(selected && !selected.customPath)
|
||||
{
|
||||
selected.name = parsePath(selected.name);
|
||||
selected.name = parsePath(selected.title);
|
||||
rebuildPath(selected.children, getPath(selected));
|
||||
}
|
||||
}" class="mx-0"/></pre>
|
||||
<pre v-else class="md:text-base text-smmd:text-nowrap text-wrap ">/{{ getPath(selected) }}</pre>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-4 flex-1 w-full max-h-full flex overflow-hidden">
|
||||
}" placeholder="Titre" style="line-height: normal;" class="flex-1 md:text-5xl text-4xl md:h-14 h-12 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 appearance-none outline-none pb-3 font-thin bg-transparent"/>
|
||||
<div class="flex flex-row justify-between items-center gap-x-4">
|
||||
<div v-if="selected.customPath" class="flex lg:items-center truncate">
|
||||
<pre class="md:text-base text-sm truncate" style="direction: rtl">/{{ selected.parent !== '' ? selected.parent + '/' : '' }}</pre>
|
||||
<TextInput v-model="selected.name" @input="(e: Event) => {
|
||||
if(selected && selected.customPath)
|
||||
{
|
||||
selected.name = parsePath(selected.name);
|
||||
rebuildPath(selected.children, getPath(selected));
|
||||
}
|
||||
}" class="mx-0 font-mono"/>
|
||||
</div>
|
||||
<pre v-else class="md:text-base text-sm truncate" style="direction: rtl">{{ getPath(selected) }}/</pre>
|
||||
<div class="flex gap-4">
|
||||
<Dialog :title="`Supprimer '${selected.title}'${selected.children?.length ?? 0 > 0 ? ' et ses enfants' : ''}`">
|
||||
<template #trigger><Button icon class="bg-light-red dark:bg-dark-red !bg-opacity-40 border-light-red dark:border-dark-red hover:bg-light-red dark:hover:bg-dark-red hover:!bg-opacity-70 hover:border-light-red dark:hover:border-dark-red"><Icon icon="radix-icons:trash" /></Button></template>
|
||||
<template #default>
|
||||
<div class="flex gap-4">
|
||||
<DialogClose><Button @click="navigation = tree.remove(navigation, getPath(selected)); selected = undefined;" class="bg-light-red dark:bg-dark-red !bg-opacity-40 border-light-red dark:border-dark-red hover:bg-light-red dark:hover:bg-dark-red hover:!bg-opacity-70 hover:border-light-red dark:hover:border-dark-red">Oui</Button></DialogClose>
|
||||
<DialogClose><Button>Non</Button></DialogClose>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<Dialog title="Préférences Markdown" v-if="selected.type === 'markdown'">
|
||||
<template #trigger><Button icon><Icon icon="radix-icons:gear" /></Button></template>
|
||||
<template #default>
|
||||
<Select label="Editeur de markdown" :modelValue="preferences.markdown.editing" @update:model-value="v => preferences.markdown.editing = (v as 'reading' | 'editing' | 'split')">
|
||||
<SelectItem label="Mode lecture" value="reading" />
|
||||
<SelectItem label="Mode edition" value="editing" />
|
||||
<SelectItem label="Ecran partagé" value="split" />
|
||||
</Select>
|
||||
</template>
|
||||
</Dialog>
|
||||
<DropdownMenu align="end" :options="[{
|
||||
type: 'checkbox',
|
||||
label: 'URL custom',
|
||||
select: (state: boolean) => { selected!.customPath = state; if(!state) selected!.name = parsePath(selected!.title) },
|
||||
checked: selected.customPath
|
||||
}]">
|
||||
<Button icon><Icon icon="radix-icons:dots-vertical"/></Button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
<div class="py-4 flex-1 w-full max-h-full flex overflow-hidden xl:px-12 lg:px-8 px-6 relative">
|
||||
<template v-if="selected.type === 'markdown'">
|
||||
<div v-if="contentStatus === 'pending'" class="flex flex-1 justify-center items-center">
|
||||
<Loading />
|
||||
</div>
|
||||
<span v-else-if="contentError">{{ contentError }}</span>
|
||||
<SplitterGroup direction="horizontal" class="flex-1 w-full flex" v-else-if="selected.content !== undefined">
|
||||
<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 }" />
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" />
|
||||
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }">
|
||||
<div class="flex-1 max-h-full !overflow-y-auto px-8" :class="{ 'hidden': isCollapsed }"><MarkdownRenderer :content="debounced" :proses="{ 'a': FakeA }" /></div>
|
||||
</SplitterPanel>
|
||||
</SplitterGroup>
|
||||
<template v-else-if="preferences.markdown.editing === 'editing'">
|
||||
<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">
|
||||
<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>
|
||||
<SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" />
|
||||
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }">
|
||||
<div class="flex-1 max-h-full !overflow-y-auto px-8" :class="{ 'hidden': isCollapsed }"><MarkdownRenderer :content="(debounced as string)" :proses="{ 'a': FakeA }" /></div>
|
||||
</SplitterPanel>
|
||||
</SplitterGroup>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="selected.type === 'canvas'">
|
||||
<span class="flex flex-1 justify-center items-center"><ProseH3>Editeur de graphe en cours de développement</ProseH3></span>
|
||||
<CanvasEditor v-if="selected.content" :modelValue="selected.content" />
|
||||
</template>
|
||||
<template v-else-if="selected.type === 'map'">
|
||||
<span class="flex flex-1 justify-center items-center"><ProseH3>Editeur de carte en cours de développement</ProseH3></span>
|
||||
</template>
|
||||
<template v-else-if="selected.type === 'file'">
|
||||
<span>Modifier le contenu :</span><input type="file" @change="(e) => console.log(e)" />
|
||||
<span>Modifier le contenu :</span><input type="file" @change="(e: Event) => console.log((e.target as HTMLInputElement).files?.length)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -174,12 +214,12 @@
|
|||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item';
|
||||
import { parsePath } from '#shared/general.utils';
|
||||
import type { FileType, TreeItem } from '~/types/content';
|
||||
import { iconByType } from '#shared/general.utils';
|
||||
import { iconByType, convertContentFromText, convertContentToText, DEFAULT_CONTENT,parsePath } from '#shared/general.util';
|
||||
import type { ExploreContent, FileType, TreeItem } from '~/types/content';
|
||||
import FakeA from '~/components/prose/FakeA.vue';
|
||||
import type { Preferences } from '~/types/general';
|
||||
|
||||
export interface TreeItemEditable extends TreeItem
|
||||
export type TreeItemEditable = TreeItem &
|
||||
{
|
||||
parent: string;
|
||||
name: string;
|
||||
|
|
@ -192,8 +232,10 @@ definePageMeta({
|
|||
layout: 'null',
|
||||
});
|
||||
|
||||
const { user } = useUserSession();
|
||||
|
||||
const router = useRouter();
|
||||
const open = ref(true);
|
||||
const open = ref(true), topOpen = ref(true);
|
||||
|
||||
const toaster = useToast();
|
||||
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
|
||||
|
|
@ -203,6 +245,8 @@ const navigation = ref<TreeItemEditable[]>(transform(JSON.parse(JSON.stringify(p
|
|||
const selected = ref<TreeItemEditable>(), edited = ref(false);
|
||||
const contentStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), contentError = ref<string>();
|
||||
|
||||
const preferences = useCookie<Preferences>('preferences', { default: () => ({ markdown: { editing: 'split' }, canvas: { snap: true, size: 32 } }), watch: true, maxAge: 60*60*24*31 });
|
||||
|
||||
watch(selected, async (value, old) => {
|
||||
if(selected.value)
|
||||
{
|
||||
|
|
@ -215,7 +259,7 @@ watch(selected, async (value, old) => {
|
|||
|
||||
if(storedEdit)
|
||||
{
|
||||
selected.value.content = storedEdit;
|
||||
selected.value.content = convertContentFromText(selected.value.type, storedEdit);
|
||||
contentStatus.value = 'success';
|
||||
}
|
||||
else
|
||||
|
|
@ -250,13 +294,13 @@ const debounced = useDebounce(content, 250, { maxWait: 500 });
|
|||
|
||||
watch(debounced, () => {
|
||||
if(selected.value && debounced.value)
|
||||
sessionStorage.setItem(`editing:${encodeURIComponent(selected.value.path)}`, debounced.value);
|
||||
sessionStorage.setItem(`editing:${encodeURIComponent(selected.value.path)}`, typeof debounced.value === 'string' ? debounced.value : JSON.stringify(debounced.value));
|
||||
});
|
||||
useShortcuts({
|
||||
meta_s: { usingInput: true, handler: () => save(false) },
|
||||
meta_n: { usingInput: true, handler: () => add('markdown') },
|
||||
meta_shift_n: { usingInput: true, handler: () => add('folder') },
|
||||
meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore-path', params: { path: 'index' } }) }
|
||||
meta_s: { usingInput: true, handler: () => save(false), prevent: true },
|
||||
meta_n: { usingInput: true, handler: () => add('markdown'), prevent: true },
|
||||
meta_shift_n: { usingInput: true, handler: () => add('folder'), prevent: true },
|
||||
meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore-path', params: { path: 'index' } }), prevent: true }
|
||||
})
|
||||
|
||||
const tree = {
|
||||
|
|
@ -386,7 +430,7 @@ function add(type: FileType): void
|
|||
|
||||
const news = [...tree.search(navigation.value, 'title', 'Nouveau')].filter((e, i, a) => a.indexOf(e) === i);
|
||||
const title = `Nouveau${news.length > 0 ? ' (' + news.length +')' : ''}`;
|
||||
const item: TreeItemEditable = { navigable: true, private: false, parent: '', path: '', title: title, name: parsePath(title), type: type, order: 0, children: type === 'folder' ? [] : undefined, customPath: false, content: type === 'markdown' ? '' : undefined, 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: [], customPath: false, content: DEFAULT_CONTENT[type], owner: -1, timestamp: new Date(), visit: 0 };
|
||||
|
||||
if(!selected.value)
|
||||
{
|
||||
|
|
@ -486,27 +530,29 @@ function rebuildPath(tree: TreeItemEditable[] | null | undefined, parentPath: st
|
|||
}
|
||||
async function save(redirect: boolean): Promise<void>
|
||||
{
|
||||
//@ts-ignore
|
||||
const map = (e: TreeItemEditable[]): TreeItemEditable[] => e.map(f => ({ ...f, content: f.content ? convertContentToText(f.type, f.content) : undefined, children: f.children ? map(f.children) : undefined }));
|
||||
saveStatus.value = 'pending';
|
||||
try {
|
||||
const result = await $fetch(`/api/project`, {
|
||||
method: 'post',
|
||||
body: navigation.value,
|
||||
body: map(navigation.value),
|
||||
});
|
||||
saveStatus.value = 'success';
|
||||
edited.value = false;
|
||||
sessionStorage.clear();
|
||||
|
||||
toaster.clear('error');
|
||||
toaster.add({
|
||||
type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000
|
||||
});
|
||||
toaster.add({ type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000 });
|
||||
|
||||
complete.value = result;
|
||||
//@ts-ignore
|
||||
complete.value = result as ExploreContent[];
|
||||
if(redirect) router.go(-1);
|
||||
} catch(e: any) {
|
||||
toaster.add({
|
||||
type: 'error', content: e.message, timer: true, duration: 10000
|
||||
})
|
||||
console.error(e);
|
||||
saveStatus.value = 'error';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,5 @@
|
|||
<Head>
|
||||
<Title>d[any] - Accueil</Title>
|
||||
</Head>
|
||||
<div class="h-full w-full flex flex-1 flex-col justify-center items-center">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden w-48 h-48" />
|
||||
<Avatar src="/logo.light.svg" class="block dark:hidden w-48 h-48" />
|
||||
<h1 class="text-5xl font-thin font-mono">Bienvenue</h1>
|
||||
</div>
|
||||
<h1 class="text-5xl font-thin font-mono">Bienvenue</h1>
|
||||
</template>
|
||||
|
|
@ -8,8 +8,8 @@
|
|||
<ProseH4>Modification de mon mot de passe</ProseH4>
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
|
||||
<TextInput type="password" label="Ancien mot de passe" autocomplete="currentPassword" v-model="oldPasswd"/>
|
||||
<TextInput type="password" label="Nouveau mot de passe" autocomplete="newPassword" v-model="newPasswd" :class="{ 'border-light-red dark:border-dark-red': error }"/>
|
||||
<TextInput type="password" label="Ancien mot de passe" name="old-password" autocomplete="current-password" v-model="oldPasswd"/>
|
||||
<TextInput type="password" label="Nouveau mot de passe" name="new-password" autocomplete="new-password" v-model="newPasswd" :class="{ 'border-light-red dark:border-dark-red': error }"/>
|
||||
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||
<span class="col-span-2">Prérequis de sécurité</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
|
||||
|
|
@ -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': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
|
||||
</div>
|
||||
<TextInput type="password" label="Repeter le nouveau mot de passe" autocomplete="newPassword" v-model="repeatPasswd" :class="{ 'border-light-red dark:border-dark-red': manualError }"/>
|
||||
<Button class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Mettre à jour mon mot de passe</Button>
|
||||
<TextInput type="password" label="Repeter le nouveau mot de passe" autocomplete="new-password" v-model="repeatPasswd" :class="{ 'border-light-red dark:border-dark-red': manualError }"/>
|
||||
<Button type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Mettre à jour mon mot de passe</Button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
<ProseH4>Connexion</ProseH4>
|
||||
</div>
|
||||
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">
|
||||
<TextInput type="text" label="Utilisateur ou email" autocomplete="username" v-model="state.usernameOrEmail"/>
|
||||
<TextInput type="password" label="Mot de passe" autocomplete="current-password" v-model="state.password"/>
|
||||
<Button class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Se connecter</Button>
|
||||
<TextInput type="text" label="Utilisateur ou email" name="username" autocomplete="username email" v-model="state.usernameOrEmail"/>
|
||||
<TextInput type="password" label="Mot de passe" name="password" autocomplete="current-password" v-model="state.password"/>
|
||||
<Button type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Se connecter</Button>
|
||||
<NuxtLink class="mt-4 text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-reset-password' }">Mot de passe oublié ?</NuxtLink>
|
||||
<NuxtLink class="mt-4 text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-register' }">Pas de compte ?</NuxtLink>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
<ProseH4>Inscription</ProseH4>
|
||||
</div>
|
||||
<form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0">
|
||||
<TextInput type="text" label="Nom d'utilisateur" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
|
||||
<TextInput type="email" label="Email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/>
|
||||
<TextInput type="password" label="Mot de passe" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/>
|
||||
<TextInput type="text" label="Nom d'utilisateur" name="username" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
|
||||
<TextInput type="email" label="Email" name="email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/>
|
||||
<TextInput type="password" label="Mot de passe" name="password" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/>
|
||||
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||
<span class="col-span-2">Prérequis de sécurité</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
|
||||
|
|
@ -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>
|
||||
</div>
|
||||
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
|
||||
<Button class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
|
||||
<Button type="submit" class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
|
||||
<span class="mt-4 order-10 flex justify-center items-center gap-4 col-span-2 md:col-span-1 m-auto">Vous avez déjà un compte ?<NuxtLink class="text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-login' }">Se connecter</NuxtLink></span>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
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 |
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const fileType = z.enum(['folder', 'file', 'markdown', 'canvas']);
|
||||
export const fileType = z.enum(['folder', 'file', 'markdown', 'canvas', 'map']);
|
||||
export const schema = z.object({
|
||||
path: z.string(),
|
||||
owner: z.number().finite(),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export const item: z.ZodType<ProjectItem> = baseItem.extend({
|
|||
});
|
||||
export const project = z.array(item);
|
||||
|
||||
type Project = z.infer<typeof project>;
|
||||
type ProjectItem = z.infer<typeof baseItem> & {
|
||||
export type ProjectItem = z.infer<typeof baseItem> & {
|
||||
children?: ProjectItem[]
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import useDatabase from '~/composables/useDatabase';
|
||||
import { explorerContentTable } from '~/db/schema';
|
||||
import { schema } from '~/schemas/file';
|
||||
import { parsePath } from '~/shared/general.utils';
|
||||
import { parsePath } from '~/shared/general.util';
|
||||
|
||||
export default defineEventHandler(async (e) => {
|
||||
const body = await readValidatedBody(e, schema.safeParse);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { eq, sql } from 'drizzle-orm';
|
||||
import useDatabase from '~/composables/useDatabase';
|
||||
import { explorerContentTable } from '~/db/schema';
|
||||
import { convertContentFromText } from '~/shared/general.util';
|
||||
|
||||
export default defineEventHandler(async (e) => {
|
||||
const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
|
||||
|
|
@ -16,6 +17,7 @@ export default defineEventHandler(async (e) => {
|
|||
const content = db.select({
|
||||
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
|
||||
'private': explorerContentTable.private,
|
||||
'type': explorerContentTable.type,
|
||||
'owner': explorerContentTable.owner,
|
||||
'visit': explorerContentTable.visit,
|
||||
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
|
||||
|
|
@ -24,12 +26,7 @@ export default defineEventHandler(async (e) => {
|
|||
{
|
||||
const session = await getUserSession(e);
|
||||
|
||||
if(content.private && (!session || !session.user))
|
||||
{
|
||||
setResponseStatus(e, 404);
|
||||
return;
|
||||
}
|
||||
if(session && session.user && session.user.id !== content.owner)
|
||||
if(!session || !session.user || session.user.id !== content.owner)
|
||||
{
|
||||
if(content.private)
|
||||
{
|
||||
|
|
@ -50,7 +47,7 @@ export default defineEventHandler(async (e) => {
|
|||
content.content = convertFromStorableLinks(content.content);
|
||||
}
|
||||
|
||||
return content.content;
|
||||
return convertContentFromText(content.type, content.content);
|
||||
}
|
||||
|
||||
setResponseStatus(e, 404);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { hasPermissions } from "#shared/auth.util";
|
||||
import useDatabase from '~/composables/useDatabase';
|
||||
import { explorerContentTable } from '~/db/schema';
|
||||
import { project } from '~/schemas/project';
|
||||
import { parsePath } from "#shared/general.utils";
|
||||
import { project, type ProjectItem } from '~/schemas/project';
|
||||
import { parsePath } from "#shared/general.util";
|
||||
import { eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import type { ExploreContent } from "~/types/content";
|
||||
import type { ExploreContent, TreeItem } from "~/types/content";
|
||||
import type { TreeItemEditable } from "~/pages/explore/edit/index.vue";
|
||||
|
||||
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('/');
|
||||
|
||||
tx.insert(explorerContentTable).values({
|
||||
path: item.path,
|
||||
path: item.path || path,
|
||||
owner: user.id,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
navigable: item.navigable,
|
||||
private: item.private,
|
||||
order: item.order,
|
||||
content: item.content ?? old.content,
|
||||
content: item.content ?? old?.content ?? null,
|
||||
}).onConflictDoUpdate({
|
||||
set: {
|
||||
path: path,
|
||||
|
|
@ -64,12 +64,12 @@ export default defineEventHandler(async (e) => {
|
|||
private: item.private,
|
||||
order: item.order,
|
||||
timestamp: new Date(),
|
||||
content: item.content ?? old.content,
|
||||
content: item.content ?? old?.content ?? null,
|
||||
},
|
||||
target: explorerContentTable.path,
|
||||
}).run();
|
||||
|
||||
if(item.path !== path)
|
||||
if(item.path !== path && !old)
|
||||
{
|
||||
tx.update(explorerContentTable).set({ content: sql`replace(${explorerContentTable.content}, ${sql.placeholder('old')}, ${sql.placeholder('new')})` }).prepare().run({ 'old': item.path, 'new': path });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import Bun from 'bun';
|
||||
import { format } from '~/shared/general.utils';
|
||||
import { format } from '~/shared/general.util';
|
||||
|
||||
const { id, userId, username, timestamp } = defineProps<{
|
||||
id: number
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,5 +1,13 @@
|
|||
import type { FileType } from '~/types/content';
|
||||
import type { CanvasContent } from '~/types/canvas';
|
||||
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
|
||||
{
|
||||
return (Array.isArray(slug) ? slug.join('/') : slug);
|
||||
|
|
@ -39,17 +47,47 @@ export function format(date: Date, template: string): string
|
|||
|
||||
return template;
|
||||
}
|
||||
export function clamp(x: number, min: number, max: number): number {
|
||||
if (x > max)
|
||||
return max;
|
||||
if (x < min)
|
||||
return min;
|
||||
return x;
|
||||
export function clamp(x: number, min: number, max: number): number
|
||||
{
|
||||
return x > max ? max : x < min ? min : x;
|
||||
}
|
||||
export function lerp(x: number, a: number, b: number): number
|
||||
{
|
||||
return (1-x)*a+x*b;
|
||||
}
|
||||
export function convertContentFromText(type: FileType, content: string): CanvasContent | string {
|
||||
switch(type)
|
||||
{
|
||||
case 'canvas':
|
||||
return JSON.parse(content) as CanvasContent;
|
||||
case 'map':
|
||||
case 'file':
|
||||
case 'folder':
|
||||
case 'markdown':
|
||||
return content;
|
||||
default:
|
||||
return content;
|
||||
}
|
||||
}
|
||||
export function convertContentToText(type: FileType, content: any): string {
|
||||
switch(type)
|
||||
{
|
||||
case 'canvas':
|
||||
return JSON.stringify(content);
|
||||
case 'map':
|
||||
case 'file':
|
||||
case 'folder':
|
||||
case 'markdown':
|
||||
return content;
|
||||
default:
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
export const iconByType: Record<FileType, string> = {
|
||||
'folder': 'lucide:folder',
|
||||
'canvas': 'ph:graph-light',
|
||||
'file': 'radix-icons:file',
|
||||
'markdown': 'radix-icons:file',
|
||||
'markdown': 'radix-icons:file-text',
|
||||
'map': 'lucide:map',
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
export interface CanvasContent {
|
||||
nodes: CanvasNode[];
|
||||
edges: CanvasEdge[];
|
||||
groups: CanvasGroup[];
|
||||
nodes?: CanvasNode[];
|
||||
edges?: CanvasEdge[];
|
||||
groups?: CanvasGroup[];
|
||||
}
|
||||
export type CanvasColor = {
|
||||
class?: string;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,52 @@
|
|||
import { explorerContentTable } from "~/db/schema";
|
||||
import type { CanvasContent } from "./canvas";
|
||||
import type { CanvasContent as Canvas } from "./canvas";
|
||||
import type { MapContent as Map } from "./map";
|
||||
|
||||
export type FileType = typeof explorerContentTable.$inferSelect.type;
|
||||
export type Overview = Omit<typeof explorerContentTable.$inferSelect, 'content'>;
|
||||
export type Content = { content: string };
|
||||
export type ExploreContent= Overview & Partial<Content>;
|
||||
export type TreeItem = ExploreContent & { children?: TreeItem[] };
|
||||
|
||||
export type FileType = keyof ContentMap;
|
||||
export interface Overview {
|
||||
path: string;
|
||||
owner: number;
|
||||
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 {
|
||||
content: Ref<ExploreContent[]>
|
||||
|
|
@ -18,16 +59,4 @@ export interface ContentComposable {
|
|||
* Get the given content from the server.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export type Preferences = {
|
||||
markdown: MarkdownPreferences,
|
||||
canvas: CanvasPreferences,
|
||||
} & GeneralPreferences;
|
||||
|
||||
type GeneralPreferences = {
|
||||
|
||||
};
|
||||
type MarkdownPreferences = {
|
||||
editing: 'split' |'reading' | 'editing';
|
||||
};
|
||||
type CanvasPreferences = {
|
||||
snap: boolean;
|
||||
size?: number;
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
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';
|
||||
}
|
||||
Loading…
Reference in New Issue