Compare commits

..

6 Commits

52 changed files with 1661 additions and 199 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -1,6 +1,10 @@
<script lang="ts">
const External = Annotation.define<boolean>();
</script>
<script setup lang="ts"> <script setup lang="ts">
import { dropCursor, crosshairCursor, keymap, EditorView } from '@codemirror/view'; import { dropCursor, crosshairCursor, keymap, EditorView, ViewUpdate } from '@codemirror/view';
import { EditorState } from '@codemirror/state'; import { Annotation, EditorState } from '@codemirror/state';
import { indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldKeymap } from '@codemirror/language'; import { indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldKeymap } from '@codemirror/language';
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'; import { history, defaultKeymap, historyKeymap } from '@codemirror/commands';
import { searchKeymap } from '@codemirror/search'; import { searchKeymap } from '@codemirror/search';
@ -36,7 +40,13 @@ onMounted(() => {
...foldKeymap, ...foldKeymap,
...completionKeymap, ...completionKeymap,
...lintKeymap ...lintKeymap
]) ]),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
{
model.value = viewUpdate.state.doc.toString();
}
})
] ]
}); });
view.value = new EditorView({ view.value = new EditorView({
@ -60,16 +70,15 @@ watchEffect(() => {
const currentValue = view.value ? view.value.state.doc.toString() : ""; const currentValue = view.value ? view.value.state.doc.toString() : "";
if (view.value && model.value !== currentValue) { if (view.value && model.value !== currentValue) {
view.value.dispatch({ view.value.dispatch({
changes: { from: 0, to: currentValue.length, insert: model.value || "" } changes: { from: 0, to: currentValue.length, insert: model.value || "" },
annotations: [External.of(true)],
}); });
} }
}); });
</script> </script>
<template> <template>
<div class="flex flex-1 justify-center items-start p-12"> <div ref="editor" class="flex flex-1 w-full justify-stretch items-stretch border border-light-35 dark:border-dark-35 caret-light-100 dark:caret-dark-100" />
<div ref="editor" class="flex flex-1 justify-center items-stretch border border-light-35 dark:border-dark-35 caret-light-100 dark:caret-dark-100" />
</div>
</template> </template>
<style> <style>

View File

@ -0,0 +1,80 @@
<template>
<TreeRoot v-bind="forward" v-slot="{ flattenItems }" class="list-none select-none border border-light-35 dark:border-dark-35 text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm overflow-auto w-[450px] 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>
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver }">
<slot :handleToggle="handleToggle"
:handleSelect="handleSelect"
:isExpanded="isExpanded"
:isSelected="isSelected"
:isDragging="isDragging"
:isDraggedOver="isDraggedOver"
:item="item"
/>
</template>
<template #hint="{ instruction }">
<div v-if="instruction">
<slot name="hint" :instruction="instruction" />
</div>
</template>
</DraggableTreeItem>
</TreeRoot>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
import { useForwardPropsEmits, type FlattenedItem, type TreeRootEmits, type TreeRootProps } from 'radix-vue';
import { type Instruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
const props = defineProps<TreeRootProps<T>>();
const emits = defineEmits<TreeRootEmits<T> & {
'updateTree': [instruction: Instruction, itemId: string, targetId: string];
}>();
defineSlots<{
default: (props: {
handleToggle: () => void,
handleSelect: () => void,
isExpanded: boolean,
isSelected: boolean,
isDragging: boolean,
isDraggedOver: boolean,
item: FlattenedItem<T>,
}) => any,
hint: (props: {
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
}) => any,
}>();
const forward = useForwardPropsEmits(props, emits);
watchEffect((onCleanup) => {
const dndFunction = combine(
monitorForElements({
onDrop(args) {
const { location, source } = args;
if (!location.current.dropTargets.length)
return;
const itemId = source.data.id as string;
const target = location.current.dropTargets[0];
const targetId = target.data.id as string;
const instruction: Instruction | null = extractInstruction(
target.data,
);
if (instruction !== null)
{
emits('updateTree', instruction, itemId, targetId);
}
},
}),
)
onCleanup(() => {
dndFunction();
})
})
</script>

View File

@ -0,0 +1,140 @@
<template>
<TreeItem ref="el" v-bind="forward" v-slot="{ isExpanded, isSelected, isIndeterminate, handleToggle, handleSelect }">
<slot
:is-expanded="isExpanded"
:is-selected="isSelected"
:is-indeterminate="isIndeterminate"
:handle-select="handleSelect"
:handle-toggle="handleToggle"
:isDragging="isDragging"
:isDraggedOver="isDraggedOver"
/>
<div v-if="instruction">
<slot name="hint" :instruction="instruction" />
</div>
</TreeItem>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
import { useForwardPropsEmits, type FlattenedItem, type TreeItemEmits, type TreeItemProps } from 'radix-vue';
import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
const props = defineProps<TreeItemProps<T> & FlattenedItem<T>>();
const emits = defineEmits<TreeItemEmits<T>>();
defineSlots<{
default: (props: {
isExpanded: boolean
isSelected: boolean
isIndeterminate: boolean | undefined
isDragging: boolean
isDraggedOver: boolean
handleToggle: () => void
handleSelect: () => void
}) => any,
hint: (props: {
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
}) => any,
}>()
const forward = useForwardPropsEmits(props, emits);
const element = templateRef('el');
const isDragging = ref(false);
const isDraggedOver = ref(false);
const isInitialExpanded = ref(false);
const instruction = ref<Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null>(null);
const mode = computed(() => {
if (props.hasChildren)
return 'expanded'
if (props.index + 1 === props.parentItem?.children?.length)
return 'last-in-group'
return 'standard'
});
watchEffect((onCleanup) => {
const currentElement = unrefElement(element) as HTMLElement;
if (!currentElement)
return
const item = { ...props.value, level: props.level, id: props._id }
const expandItem = () => {
if (!element.value?.isExpanded) {
element.value?.handleToggle()
}
}
const closeItem = () => {
if (element.value?.isExpanded) {
element.value?.handleToggle()
}
}
const dndFunction = combine(
draggable({
element: currentElement,
getInitialData: () => item,
onDragStart: () => {
isDragging.value = true
isInitialExpanded.value = element.value?.isExpanded ?? false
closeItem()
},
onDrop: () => {
isDragging.value = false
if (isInitialExpanded.value)
expandItem()
},
}),
dropTargetForElements({
element: currentElement,
getData: ({ input, element }) => {
const data = { id: item.id }
return attachInstruction(data, {
input,
element,
indentPerLevel: 16,
currentLevel: props.level,
mode: mode.value,
block: [],
})
},
canDrop: ({ source }) => {
return source.data.id !== item.id
},
onDrag: ({ self }) => {
instruction.value = extractInstruction(self.data) as typeof instruction.value
},
onDragEnter: ({ source }) => {
if (source.data.id !== item.id) {
isDraggedOver.value = true
}
},
onDragLeave: () => {
isDraggedOver.value = false
instruction.value = null
},
onDrop: ({ location }) => {
isDraggedOver.value = false
instruction.value = null
},
getIsSticky: () => true,
}),
monitorForElements({
canMonitor: ({ source }) => {
return source.data.id !== item.id
},
}),
)
// Cleanup dnd function
onCleanup(() => dndFunction())
})
</script>

View File

@ -0,0 +1,66 @@
<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">
<Icon v-if="item.icon" :icon="item.icon" class="absolute left-1.5" />
<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>
</DropdownMenuItem>
</template>
<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>
</template>
<template v-if="item.type === 'radio'">
<DropdownMenuLabel>{{ item.label }}</DropdownMenuLabel>
<DropdownMenuRadioGroup @update:model-value="item.change">
<DropdownMenuRadioItem v-for="option in item.items" :disabled="(option as any)?.disabled ?? false" :value="(option as any)?.value ?? option">
<DropdownMenuItemIndicator>
<Icon icon="radix-icons:dot-filled" />
</DropdownMenuItemIndicator>
<span>{{ (option as any)?.label || option }}</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator v-if="idx !== options.length - 1" />
</template>
<template v-if="item.type === 'submenu'">
<DropdownMenuSub>
<DropdownMenuSubTrigger class="group cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<Icon v-if="item.icon" :icon="item.icon" />
<span>{{ item.label }}</span>
<Icon icon="radix-icons:chevron-right" class="absolute right-1" />
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
<DropdownContentRender :options="item.items" />
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</template>
<template v-if="item.type === 'group'">
<DropdownMenuLabel class="text-light-70 dark:text-dark-70 text-sm text-center pt-1">{{ item.label }}</DropdownMenuLabel>
<DropdownContentRender :options="item.items" />
<DropdownMenuSeparator v-if="idx !== options.length - 1" />
</template>
</template>
</template>
<script setup lang="ts">
import type { DropdownOption } from './DropdownMenu.vue';
import { Icon } from '@iconify/vue/dist/iconify.js';
const { options } = defineProps<{
options: DropdownOption[]
}>();
</script>

View File

@ -0,0 +1,58 @@
<template>
<DropdownMenuRoot>
<DropdownMenuTrigger :disabled="disabled" ><slot /></DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent :align="align" :side="side" class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
<DropdownContentRender :options="options" />
<DropdownMenuArrow class="fill-light-35 dark:fill-dark-35" />
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
<script setup lang="ts">
export interface DropdownItem {
type: 'item';
label: string;
disabled?: boolean;
select?: () => void;
icon?: string;
kbd?: string;
}
export interface DropdownCheckbox {
type: 'checkbox';
label: string;
disabled?: boolean;
checked?: boolean | Ref<boolean>
select?: (state: boolean) => void;
kbd?: string;
}
export interface DropdownRadioGroup {
type: 'radio';
label: string;
value?: string | Ref<string>
items: (string | {label: string, value?: string, disabled?: boolean})[];
change?: (value: string) => void;
}
export interface DropdownSubmenu {
type: 'submenu';
label: string;
disabled?: boolean;
items: DropdownOption[];
icon?: string;
}
export interface DropdownGroup {
type: 'group';
label?: string;
items: DropdownOption[];
}
export type DropdownOption = DropdownItem | DropdownCheckbox | DropdownRadioGroup | DropdownSubmenu | DropdownGroup;
const { options, disabled = false, side, align } = defineProps<{
options: DropdownOption[]
disabled?: boolean
side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
}>();
</script>

View File

@ -1,5 +1,5 @@
<template> <template>
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm" :items="model" :get-key="(item) => item.link ?? item.label"> <TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm" :items="model" :get-key="(item) => item.link ?? item.label" :defaultExpanded="flatten(model)">
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level - 0.5}em` }" v-bind="item.bind" class="flex items-center px-2 outline-none relative cursor-pointer"> <TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level - 0.5}em` }" v-bind="item.bind" class="flex items-center px-2 outline-none relative cursor-pointer">
<NuxtLink :href="item.value.link && !item.hasChildren ? { name: 'explore-path', params: { path: item.value.link } } : undefined" no-prefetch class="flex flex-1 items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" :class="{ 'border-s': !item.hasChildren, 'font-medium': item.hasChildren }" active-class="text-accent-blue border-s-2 !border-accent-blue"> <NuxtLink :href="item.value.link && !item.hasChildren ? { name: 'explore-path', params: { path: item.value.link } } : undefined" no-prefetch class="flex flex-1 items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" :class="{ 'border-s': !item.hasChildren, 'font-medium': item.hasChildren }" active-class="text-accent-blue border-s-2 !border-accent-blue">
<Icon v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level - 1}em` }" /> <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` }" />
@ -19,9 +19,15 @@ interface TreeItem
label: string label: string
link?: string link?: string
tag?: string tag?: string
open?: boolean
children?: TreeItem[] children?: TreeItem[]
} }
const model = defineModel<TreeItem[]>(); const model = defineModel<TreeItem[]>();
function flatten(arr: TreeItem[]): string[]
{
return arr.filter(e => e.open).flatMap(e => [e?.link ?? e.label, ...flatten(e.children ?? [])]);
}
</script> </script>
<style> <style>

View File

@ -2,7 +2,7 @@
import { useDrag, usePinch, useWheel } from '@vueuse/gesture'; import { useDrag, usePinch, useWheel } from '@vueuse/gesture';
import type { CanvasContent, CanvasNode } from '~/types/canvas'; import type { CanvasContent, CanvasNode } from '~/types/canvas';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import { clamp } from '#imports'; import { clamp } from '#shared/general.utils';
interface Props interface Props
{ {
@ -151,16 +151,12 @@ dark:border-dark-purple
*/ */
const pinchHandler = usePinch(({ event, offset: [z] }: { event: Event, offset: number[] }) => { const pinchHandler = usePinch(({ event, offset: [z] }: { event: Event, offset: number[] }) => {
event.stopPropagation();
event.preventDefault();
zoom.value = clamp(z / 2048, minZoom.value, 3); zoom.value = clamp(z / 2048, minZoom.value, 3);
}, { }, {
domTarget: canvas, domTarget: canvas,
eventOptions: { passive: false, } eventOptions: { passive: false, }
}) })
const dragHandler = useDrag(({ event, delta: [x, y] }: { event: Event, delta: number[] }) => { const dragHandler = useDrag(({ event, delta: [x, y] }: { event: Event, delta: number[] }) => {
event.stopPropagation();
event.preventDefault();
dispX.value += x / zoom.value; dispX.value += x / zoom.value;
dispY.value += y / zoom.value; dispY.value += y / zoom.value;
}, { }, {
@ -168,8 +164,6 @@ const dragHandler = useDrag(({ event, delta: [x, y] }: { event: Event, delta: nu
eventOptions: { passive: false, } eventOptions: { passive: false, }
}) })
const wheelHandler = useWheel(({ event, delta: [x, y] }: { event: Event, delta: number[] }) => { const wheelHandler = useWheel(({ event, delta: [x, y] }: { event: Event, delta: number[] }) => {
event.stopPropagation();
event.preventDefault();
zoom.value = clamp(zoom.value + y * -0.001, minZoom.value, 3); zoom.value = clamp(zoom.value + y * -0.001, minZoom.value, 3);
}, { }, {
domTarget: canvas, domTarget: canvas,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,57 +1,5 @@
<template> <template>
<!-- <HoverPopup @before-show="fetch">
<template #content>
<Suspense suspensible>
<div class="mw-[400px]">
<div v-if="fetched === false" class="loading w-[400px] h-[150px]"></div>
<template v-else-if="!!data">
<div v-if="data.description" class="pb-4 pt-3 px-8">
<span class="text-2xl font-semibold">#{{ data.tag }}</span>
<Markdown :content="data.description"></Markdown>
</div>
<div class="h-100 w-100 flex flex-1 flex-col justify-center items-center" v-else>
<div class="text-3xl font-extralight tracking-wide text-light-60 dark:text-dark-60">Fichier vide</div>
<div class="text-lg text-light-60 dark:text-dark-60">Cette page est vide</div>
</div>
</template>
<div class="h-100 w-100 flex flex-1 flex-col justify-center items-center" v-else>
<div class="text-3xl font-extralight tracking-wide text-light-60 dark:text-dark-60">Impossible d'afficher</div>
<div class="text-lg text-light-60 dark:text-dark-60">Cette page est impossible à traiter</div>
</div>
</div>
<template #fallback><div class="loading w-[400px] h-[150px]"></div></template>
</Suspense>
</template>
<template #default>
<span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30"> <span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30">
<slot></slot> <slot></slot>
</span> </span>
</template> </template>
</HoverPopup> -->
<span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30">
<slot></slot>
</span>
</template>
<!-- <script setup lang="ts">
import type { Tag } from '~/types/api';
const { tag } = defineProps({
tag: {
type: String,
required: true,
}
});
const data = ref<Tag>(), fetched = ref(false);
const route = useRoute();
const project = computed(() => parseInt(Array.isArray(route.params.projectId) ? '0' : route.params.projectId));
async function fetch()
{
if(fetched.value)
return;
data.value = await $fetch(`/api/project/${project.value}/tags/${encodeURIComponent(tag)}`);
fetched.value = true;
}
</script> -->

View File

@ -9,7 +9,7 @@ export default function useDatabase()
{ {
const database = useRuntimeConfig().database; const database = useRuntimeConfig().database;
const sqlite = new Database(database); const sqlite = new Database(database);
instance = drizzle({ client: sqlite, schema }); instance = drizzle({ client: sqlite, schema, /* logger: true */ });
instance.run("PRAGMA journal_mode = WAL;"); instance.run("PRAGMA journal_mode = WAL;");
instance.run("PRAGMA foreign_keys = true;"); instance.run("PRAGMA foreign_keys = true;");

191
composables/useShortcuts.ts Normal file
View File

@ -0,0 +1,191 @@
import { ref, computed } from 'vue'
import type { ComputedRef, WatchSource } from 'vue'
import { logicAnd, logicNot } from '@vueuse/math'
import { useEventListener, useDebounceFn, createSharedComposable, useActiveElement } from '@vueuse/core'
export interface ShortcutConfig {
handler: Function
usingInput?: string | boolean
whenever?: WatchSource<boolean>[]
}
export interface ShortcutsConfig {
[key: string]: ShortcutConfig | Function
}
export interface ShortcutsOptions {
chainDelay?: number
}
interface Shortcut {
handler: Function
condition: ComputedRef<boolean>
chained: boolean
// KeyboardEvent attributes
key: string
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
altKey: boolean
// code?: string
// keyCode?: number
}
const chainedShortcutRegex = /^[^-]+.*-.*[^-]+$/
const combinedShortcutRegex = /^[^_]+.*_.*[^_]+$/
export const useShortcuts = (config: ShortcutsConfig, options: ShortcutsOptions = {}) => {
const { macOS, usingInput } = _useShortcuts()
let shortcuts: Shortcut[] = []
const chainedInputs = ref<string[]>([])
const clearChainedInput = () => {
chainedInputs.value.splice(0, chainedInputs.value.length)
}
const debouncedClearChainedInput = useDebounceFn(clearChainedInput, options.chainDelay ?? 800)
const onKeyDown = (e: KeyboardEvent) => {
// Input autocomplete triggers a keydown event
if (!e.key) { return }
const alphabeticalKey = /^[a-z]{1}$/i.test(e.key)
let chainedKey
chainedInputs.value.push(e.key)
// try matching a chained shortcut
if (chainedInputs.value.length >= 2) {
chainedKey = chainedInputs.value.slice(-2).join('-')
for (const shortcut of shortcuts.filter(s => s.chained)) {
if (shortcut.key !== chainedKey) { continue }
if (shortcut.condition.value) {
e.preventDefault()
shortcut.handler()
}
clearChainedInput()
return
}
}
// try matching a standard shortcut
for (const shortcut of shortcuts.filter(s => !s.chained)) {
if (e.key.toLowerCase() !== shortcut.key) { continue }
if (e.metaKey !== shortcut.metaKey) { continue }
if (e.ctrlKey !== shortcut.ctrlKey) { continue }
// shift modifier is only checked in combination with alphabetical keys
// (shift with non-alphabetical keys would change the key)
if (alphabeticalKey && e.shiftKey !== shortcut.shiftKey) { continue }
// alt modifier changes the combined key anyways
// if (e.altKey !== shortcut.altKey) { continue }
if (shortcut.condition.value) {
e.preventDefault()
shortcut.handler()
}
clearChainedInput()
return
}
debouncedClearChainedInput()
}
// Map config to full detailled shortcuts
shortcuts = Object.entries(config).map(([key, shortcutConfig]) => {
if (!shortcutConfig) {
return null
}
// Parse key and modifiers
let shortcut: Partial<Shortcut>
if (key.includes('-') && key !== '-' && !key.match(chainedShortcutRegex)?.length) {
console.trace(`[Shortcut] Invalid key: "${key}"`)
}
if (key.includes('_') && key !== '_' && !key.match(combinedShortcutRegex)?.length) {
console.trace(`[Shortcut] Invalid key: "${key}"`)
}
const chained = key.includes('-') && key !== '-'
if (chained) {
shortcut = {
key: key.toLowerCase(),
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false
}
} else {
const keySplit = key.toLowerCase().split('_').map(k => k)
shortcut = {
key: keySplit.filter(k => !['meta', 'ctrl', 'shift', 'alt'].includes(k)).join('_'),
metaKey: keySplit.includes('meta'),
ctrlKey: keySplit.includes('ctrl'),
shiftKey: keySplit.includes('shift'),
altKey: keySplit.includes('alt')
}
}
shortcut.chained = chained
// Convert Meta to Ctrl for non-MacOS
if (!macOS.value && shortcut.metaKey && !shortcut.ctrlKey) {
shortcut.metaKey = false
shortcut.ctrlKey = true
}
// Retrieve handler function
if (typeof shortcutConfig === 'function') {
shortcut.handler = shortcutConfig
} else if (typeof shortcutConfig === 'object') {
shortcut = { ...shortcut, handler: shortcutConfig.handler }
}
if (!shortcut.handler) {
console.trace('[Shortcut] Invalid value')
return null
}
// Create shortcut computed
const conditions: ComputedRef<boolean>[] = []
if (!(shortcutConfig as ShortcutConfig).usingInput) {
conditions.push(logicNot(usingInput))
} else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') {
conditions.push(computed(() => usingInput.value === (shortcutConfig as ShortcutConfig).usingInput))
}
shortcut.condition = logicAnd(...conditions, ...((shortcutConfig as ShortcutConfig).whenever || []))
return shortcut as Shortcut
}).filter(Boolean) as Shortcut[]
useEventListener('keydown', onKeyDown)
}
export const _useShortcuts = () => {
const macOS = computed(() => process.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/))
const metaSymbol = ref(' ')
const activeElement = useActiveElement()
const usingInput = computed(() => {
const usingInput = !!(activeElement.value?.tagName === 'INPUT' || activeElement.value?.tagName === 'TEXTAREA' || activeElement.value?.contentEditable === 'true')
if (usingInput) {
return ((activeElement.value as any)?.name as string) || true
}
return false
})
onMounted(() => {
metaSymbol.value = macOS.value ? '⌘' : 'Ctrl'
})
return {
macOS,
metaSymbol,
activeElement,
usingInput
}
}

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -39,8 +39,9 @@ export const explorerContentTable = sqliteTable("explorer_content", {
title: text().notNull(), title: text().notNull(),
type: text({ enum: ['file', 'folder', 'markdown', 'canvas'] }).notNull(), type: text({ enum: ['file', 'folder', 'markdown', 'canvas'] }).notNull(),
content: blob({ mode: 'buffer' }), content: blob({ mode: 'buffer' }),
navigable: int({ mode: 'boolean' }).default(true), navigable: int({ mode: 'boolean' }).notNull().default(true),
private: int({ mode: 'boolean' }).default(false), private: int({ mode: 'boolean' }).notNull().default(false),
order: int().unique('order').notNull(),
}); });
export const usersRelation = relations(usersTable, ({ one, many }) => ({ export const usersRelation = relations(usersTable, ({ one, many }) => ({

View File

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

View File

@ -0,0 +1,313 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a1a7b478-d0c3-4fc6-b74a-1a010c1d8ca1",
"prevId": "6da7ff20-0db8-4055-a353-bb0ea2fa5e0b",
"tables": {
"explorer_content": {
"name": "explorer_content",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"order": {
"name": "order",
"columns": [
"order"
],
"isUnique": true
}
},
"foreignKeys": {
"explorer_content_owner_users_id_fk": {
"name": "explorer_content_owner_users_id_fk",
"tableFrom": "explorer_content",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -22,6 +22,13 @@
"when": 1730985155814, "when": 1730985155814,
"tag": "0002_messy_solo", "tag": "0002_messy_solo",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1731344368953,
"tag": "0003_cultured_skaar",
"breakpoints": true
} }
] ]
} }

View File

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

View File

@ -12,12 +12,27 @@
</div> </div>
<div class="flex items-center px-2"> <div class="flex items-center px-2">
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip> <Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
<Tooltip :message="loggedIn ? 'Mon profil' : 'Se connecter'" side="right"> <Tooltip v-if="!loggedIn" :message="'Se connecter'" side="right">
<NuxtLink class="" :to="{ name: 'user-profile' }">
<div class="hover:border-opacity-70 flex"> <div class="hover:border-opacity-70 flex">
<Icon :icon="loggedIn ? 'radix-icons:avatar' : 'radix-icons:person'" class="w-7 h-7 p-1" /> <Icon :icon="'radix-icons:person'" class="w-7 h-7 p-1" />
</div> </div>
</NuxtLink> </Tooltip>
<Tooltip v-else :message="'Mon profil'" side="right">
<DropdownMenu :options="[{
type: 'item',
label: 'Mon profil',
icon: 'radix-icons:avatar',
select: () => useRouter().push({ name: 'user-profile' }),
}, {
type: 'item',
label: 'Deconnexion',
icon: 'radix-icons:close',
select: () => clear(),
}]" side="right" align="start">
<div class="hover:border-opacity-70 flex">
<Icon :icon="'radix-icons:avatar'" class="w-7 h-7 p-1" />
</div>
</DropdownMenu>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@ -32,18 +47,39 @@
</NuxtLink> </NuxtLink>
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip> <Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
<Tooltip :message="loggedIn ? 'Mon profil' : 'Se connecter'" side="right"> <Tooltip v-if="!loggedIn" :message="'Se connecter'" side="right">
<NuxtLink class="" :to="{ name: 'user-profile' }"> <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"> <div class="bg-light-20 dark:bg-dark-20 hover:border-opacity-70 flex border p-px border-light-50 dark:border-dark-50">
<Icon :icon="loggedIn ? 'radix-icons:avatar' : 'radix-icons:person'" class="w-7 h-7 p-1" /> <Icon :icon="'radix-icons:person'" class="w-7 h-7 p-1" />
</div> </div>
</NuxtLink> </NuxtLink>
</Tooltip> </Tooltip>
<Tooltip v-else :message="'Mon profil'" side="right">
<DropdownMenu :options="[{
type: 'item',
label: 'Mon profil',
select: () => useRouter().push({ name: 'user-profile' }),
}, {
type: 'item',
label: 'Deconnexion',
select: () => clear(),
}]" 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>
</div> </div>
<Tree v-if="pages" v-model="pages" class="flex-1 xl:px-6 px-3 max-w-full max-h-full overflow-y-auto overflow-x-hidden"/> <div class="flex-1 xl:px-6 px-3 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
<NuxtLink :href="{ name: 'explore' }" no-prefetch 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">
<div class="pl-3 py-1 flex-1 truncate">Projet</div>
</NuxtLink>
<Tree v-if="pages" v-model="pages"/>
</div>
<div class="xl:px-12 px-6 text-start text-xs text-light-60 dark:text-dark-60 relative top-4"> <div class="xl:px-12 px-6 text-start text-xs text-light-60 dark:text-dark-60 relative top-4">
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> -
<NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink> <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
<p>Copyright Peaceultime - 2024</p> <p>Copyright Peaceultime - 2024</p>
</div> </div>
@ -56,20 +92,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import type { NavigationTreeItem } from '~/server/api/navigation.get';
const open = ref(false); const open = ref(false);
const { loggedIn } = useUserSession(); const { loggedIn, clear } = useUserSession();
const { data: pages } = await useLazyFetch('/api/navigation', { const route = useRouter().currentRoute;
transform: transform, const path = computed(() => route.value.params.path ? Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path : undefined);
});
watch(useRouter().currentRoute, () => { watch(route, () => {
open.value = false; open.value = false;
}); });
function transform(list: any[]): any[] const { data: pages } = await useLazyFetch('/api/navigation', {
transform: transform,
watch: [useRouter().currentRoute]
});
function transform(list: NavigationTreeItem[] | undefined): any[] | undefined
{ {
return list?.map(e => ({ label: e.title, children: transform(e.children), link: e.path, tag: e.private ? 'private' : e.type })) return list?.map(e => ({ label: e.title, children: transform(e?.children ?? undefined), link: e.path, tag: e.private ? 'private' : e.type, open: path.value?.startsWith(e.path)}))
} }
</script> </script>

View File

@ -1,3 +1,5 @@
import { hasPermissions } from "#shared/auth.util";
export default defineNuxtRouteMiddleware(async (to, from) => { export default defineNuxtRouteMiddleware(async (to, from) => {
const { loggedIn, fetch, user } = useUserSession(); const { loggedIn, fetch, user } = useUserSession();
const meta = to.meta; const meta = to.meta;

View File

@ -7,23 +7,42 @@
"dev": "bunx --bun nuxi dev" "dev": "bunx --bun nuxi dev"
}, },
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@iconify/vue": "^4.1.2", "@iconify/vue": "^4.1.2",
"@nuxtjs/color-mode": "^3.5.2", "@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/tailwindcss": "^6.12.2", "@nuxtjs/tailwindcss": "^6.12.2",
"@vueuse/gesture": "^2.0.0",
"@vueuse/math": "^11.2.0",
"@vueuse/nuxt": "^11.1.0", "@vueuse/nuxt": "^11.1.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"drizzle-orm": "^0.35.3", "drizzle-orm": "^0.35.3",
"hast": "^1.0.0",
"lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.1",
"nuxt": "^3.14.159", "nuxt": "^3.14.159",
"nuxt-security": "^2.0.0", "nuxt-security": "^2.0.0",
"radix-vue": "^1.9.8", "radix-vue": "^1.9.8",
"rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vue": "latest", "vue": "latest",
"vue-router": "latest", "vue-router": "latest",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.12", "@types/bun": "^1.1.12",
"@types/lodash.capitalize": "^4.2.9",
"@types/unist": "^3.0.3",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",
"bun-types": "^1.1.34", "bun-types": "^1.1.34",
"drizzle-kit": "^0.26.2" "drizzle-kit": "^0.26.2",
"mdast-util-to-string": "^4.0.0",
"rehype-stringify": "^10.0.1"
} }
} }

View File

@ -40,14 +40,13 @@ async function fetch()
<template> <template>
<Head> <Head>
<Title>Administration</Title> <Title>d[any] - Administration</Title>
</Head> </Head>
<div class="flex flex-col justify-start"> <div class="flex flex-col justify-start">
<ProseH2>Administration</ProseH2> <ProseH2>Administration</ProseH2>
<Select label="Job" v-model="job"> <Select label="Job" v-model="job">
<SelectItem label="Synchroniser" value="sync" /> <SelectItem label="Récupérer les données d'Obsidian" value="pull" />
<SelectItem label="Nettoyer la base" value="clear" disabled /> <SelectItem label="Envoyer les données dans Obsidian" value="push" disabled />
<SelectItem label="Reconstruire" value="rebuild" disabled />
</Select> </Select>
<Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'"> <Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'">
<span>Executer</span> <span>Executer</span>

View File

@ -1,6 +1,6 @@
<template> <template>
<Head> <Head>
<Title>Editeur</Title> <Title>d[any] - Editeur</Title>
</Head> </Head>
<Editor v-model="model" /> <Editor v-model="model" />
</template> </template>

View File

@ -42,6 +42,14 @@
<script setup lang="ts"> <script setup lang="ts">
const route = useRouter().currentRoute; const route = useRouter().currentRoute;
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path); const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
watch(path, () => {
if(path.value === 'index')
{
useRouter().replace({ name: 'explore' });
}
}, { immediate: true });
const { loggedIn, user } = useUserSession(); const { loggedIn, user } = useUserSession();
const { data: page, status, error } = await useFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [route, path], }); const { data: page, status, error } = await useFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [route, path], });

View File

@ -1,21 +1,26 @@
<template> <template>
<div v-if="page" class="xl:p-12 lg:p-8 py-4 flex flex-1 flex-col items-start justify-start max-h-full"> <div v-if="page" class="xl:p-12 lg:p-8 py-4 flex flex-1 flex-col items-start justify-start max-h-full">
<Head> <Head>
<Title>Modification de {{ page.title }}</Title> <Title>d[any] - Modification de {{ page.title }}</Title>
</Head> </Head>
<div class="flex flex-col xl:flex-row xl:justify-between justify-center items-center w-full px-4 pb-4 border-b border-light-35 dark:border-dark-35 bg-light-0 dark:bg-dark-0"> <div class="flex flex-col xl:flex-row xl:justify-between justify-center items-center w-full px-4 pb-4 border-b border-light-35 dark:border-dark-35 bg-light-0 dark:bg-dark-0">
<input type="text" v-model="page.title" placeholder="Titre" class="flex-1 mx-4 h-16 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 px-3 py-1 text-5xl font-thin bg-transparent" /> <input type="text" v-model="page.title" placeholder="Titre" class="flex-1 mx-4 h-16 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 px-3 py-1 text-5xl font-thin bg-transparent" />
<div class="flex gap-4 self-end xl:self-auto flex-wrap"> <div class="flex gap-4 self-end xl:self-auto flex-wrap">
<div class="flex gap-4">
<Tooltip message="Consultable uniquement par le propriétaire" side="bottom"><Switch label="Privé" v-model="page.private" /></Tooltip> <Tooltip message="Consultable uniquement par le propriétaire" side="bottom"><Switch label="Privé" v-model="page.private" /></Tooltip>
<Tooltip message="Afficher dans le menu de navigation" side="bottom"><Switch label="Navigable" v-model="page.navigable" /></Tooltip> <Tooltip message="Afficher dans le menu de navigation" side="bottom"><Switch label="Navigable" v-model="page.navigable" /></Tooltip>
<Button @click="() => save()" :loading="saveStatus === 'pending'" class="border-light-blue dark:border-dark-blue hover:border-light-blue dark:hover:border-dark-blue focus:shadow-light-blue dark:focus:shadow-dark-blue">Enregistrer</Button> </div>
<div class="flex gap-4">
<Tooltip message="Ctrl+S" side="bottom"><Button @click="() => save(true)" :loading="saveStatus === 'pending'" class="border-light-blue dark:border-dark-blue hover:border-light-blue dark:hover:border-dark-blue focus:shadow-light-blue dark:focus:shadow-dark-blue">Enregistrer</Button></Tooltip>
<Tooltip message="Ctrl+Shift+Z" side="bottom"><NuxtLink :href="{ name: 'explore-path', params: { path: path } }"><Button>Annuler</Button></NuxtLink></Tooltip>
</div>
</div> </div>
</div> </div>
<div class="my-4 flex-1 w-full max-h-full flex"> <div class="my-4 flex-1 w-full max-h-full flex">
<template v-if="page.type === 'markdown'"> <template v-if="page.type === 'markdown'">
<SplitterGroup direction="horizontal" class="flex-1 w-full flex" > <SplitterGroup direction="horizontal" class="flex-1 w-full flex" >
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }" :defaultSize="50"> <SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }" :defaultSize="50">
<textarea v-model="page.content" class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto" :class="{ 'hidden': isCollapsed }"></textarea> <Editor v-model="page.content" class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto" :class="{ 'hidden': isCollapsed }" />
</SplitterPanel> </SplitterPanel>
<SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" /> <SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" />
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }"> <SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }">
@ -33,7 +38,7 @@
</div> </div>
<div v-else-if="status === 'pending'" class="flex"> <div v-else-if="status === 'pending'" class="flex">
<Head> <Head>
<Title>Chargement</Title> <Title>d[any] - Chargement</Title>
</Head> </Head>
<Loading /> <Loading />
</div> </div>
@ -43,41 +48,69 @@
<script setup lang="ts"> <script setup lang="ts">
import FakeA from '~/components/prose/FakeA.vue'; import FakeA from '~/components/prose/FakeA.vue';
const route = useRouter().currentRoute; const nuxt = useNuxtApp();
const router = useRouter();
const route = router.currentRoute;
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path); const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
const { user, loggedIn } = useUserSession(); const { user, loggedIn } = useUserSession();
const toaster = useToast(); const toaster = useToast();
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle'); const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
const { data: page, status, error } = await useLazyFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [ route, path ]}); const sessionContent = useSessionStorage<string | undefined>(path.value, undefined);
const { data: page, status, error } = await useFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [ route, path ], transform: (value) => {
if(value && sessionContent.value !== undefined)
{
value.content = sessionContent.value;
}
return value;
}, getCachedData: (key) => {
const value = nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key];
if(value && sessionContent.value !== undefined)
{
value.content = sessionContent.value;
}
return value;
} });
const content = computed(() => page.value?.content); const content = computed(() => page.value?.content);
const debounced = useDebounce(content, 250); const debounced = useDebounce(content, 250);
if(!loggedIn || (page.value && page.value.owner !== user.value?.id)) if(!loggedIn || (page.value && page.value.owner !== user.value?.id))
{ {
useRouter().replace({ name: 'explore-path', params: { path: path.value } }); router.replace({ name: 'explore-path', params: { path: path.value } });
} }
async function save(): Promise<void> watch(debounced, (value) => {
sessionContent.value = value;
});
useShortcuts({
meta_s: { usingInput: true, handler: () => save(false) },
meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore-path', params: { path: path.value }}) }
})
async function save(redirect: boolean): Promise<void>
{ {
saveStatus.value = 'pending'; saveStatus.value = 'pending';
try { try {
await $fetch(`/api/file`, { await $fetch(`/api/file`, {
method: 'post', method: 'post',
body: page.value, body: page.value,
headers: {
'Content-Type': 'application/json',
},
}); });
saveStatus.value = 'success'; saveStatus.value = 'success';
sessionContent.value = undefined;
toaster.clear('error'); toaster.clear('error');
toaster.add({ toaster.add({
type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000 type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000
}); });
useRouter().push({ name: 'explore-path', params: { path: path.value } }); if(redirect)
router.push({ name: 'explore-path', params: { path: path.value } });
} catch(e: any) { } catch(e: any) {
toaster.add({ toaster.add({
type: 'error', content: e.message, timer: true, duration: 10000 type: 'error', content: e.message, timer: true, duration: 10000

View File

@ -0,0 +1,284 @@
<template>
<Head>
<Title>d[any] - Configuration du projet</Title>
</Head>
<div class="flex flex-1 flex-row gap-4 p-6 items-start" v-if="navigation">
<DraggableTree class="list-none select-none border border-light-35 dark:border-dark-35 text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm overflow-auto w-[450px] max-h-full"
:items="navigation ?? undefined" :get-key="(item: Partial<ProjectItem>) => item.path ? getPath(item as ProjectItem) : ''" @updateTree="drop">
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver, item }">
<div class="flex flex-1 px-2" :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>
<div class="ps-2 py-1 flex-1 truncate" :class="{'!ps-4 border-s border-light-35 dark:border-dark-35': !item.hasChildren}" :title="item.value.title" @click="() => { handleSelect(); selected = isSelected ? undefined : item.value; }">
{{ item.value.title }}
</div>
</div>
</template>
<template #hint="{ instruction }">
<div v-if="instruction" class="absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50" :style="{
width: `calc(100% - ${instruction.currentLevel - 1}em)`
}" :class="{
'!border-b-4': instruction?.type === 'reorder-below',
'!border-t-4': instruction?.type === 'reorder-above',
'!border-4': instruction?.type === 'make-child',
}"></div>
</template>
</DraggableTree>
<div class="flex flex-col flex-1">
<div class="flex self-end gap-4 px-4">
<Tooltip message="Ctrl+S" side="bottom"><Button @click="() => save(true)" :loading="saveStatus === 'pending'" class="border-light-blue dark:border-dark-blue hover:border-light-blue dark:hover:border-dark-blue focus:shadow-light-blue dark:focus:shadow-dark-blue">Enregistrer</Button></Tooltip>
<Tooltip message="Ctrl+Shift+Z" side="bottom"><NuxtLink :href="{ name: 'explore' }"><Button>Annuler</Button></NuxtLink></Tooltip>
</div>
<div v-if="selected" class="flex-1 flex justify-start items-start">
<div class="flex flex-col flex-1 justify-start items-start">
<input type="text" v-model="selected.title" placeholder="Titre" class="flex-1 mx-4 h-16 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 px-3 py-1 text-5xl font-thin bg-transparent" />
<span><pre class="ps-2 inline">{{ selected.parent }}/</pre><input v-model="selected.name" placeholder="Titre" class="font-mono border-b border-light-35 dark:border-dark-35 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 bg-transparent" /></span>
<div class="flex ms-6 flex-col justify-start items-start">
<Tooltip message="Consultable uniquement par le propriétaire" side="right"><Switch label="Privé" v-model="selected.private" /></Tooltip>
<Tooltip message="Afficher dans le menu de navigation" side="right"><Switch label="Navigable" v-model="selected.navigable" /></Tooltip>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item';
import { parsePath } from '#shared/general.utils';
import type { ProjectItem } from '~/schemas/project';
definePageMeta({
rights: ['admin', 'editor'],
});
const router = useRouter();
const route = router.currentRoute;
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
const toaster = useToast();
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
const { data: project } = await useFetch(`/api/project`);
const navigation = computed({
get: () => project.value?.items,
set: (value) => {
const proj = project.value;
if(proj && value)
proj.items = value;
project.value = proj;
}
});
const selected = ref<ProjectItem>();
useShortcuts({
meta_s: { usingInput: true, handler: () => save(false) },
meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore-path', params: { path: path.value }}) }
})
const tree = {
remove(data: ProjectItem[], id: string): ProjectItem[] {
return data
.filter(item => getPath(item) !== id)
.map((item) => {
if (tree.hasChildren(item)) {
return {
...item,
children: tree.remove(item.children ?? [], id),
};
}
return item;
});
},
insertBefore(data: ProjectItem[], targetId: string, newItem: ProjectItem): ProjectItem[] {
return data.flatMap((item) => {
if (getPath(item) === targetId)
return [newItem, item];
if (tree.hasChildren(item)) {
return {
...item,
children: tree.insertBefore(item.children ?? [], targetId, newItem),
};
}
return item;
});
},
insertAfter(data: ProjectItem[], targetId: string, newItem: ProjectItem): ProjectItem[] {
return data.flatMap((item) => {
if (getPath(item) === targetId)
return [item, newItem];
if (tree.hasChildren(item)) {
return {
...item,
children: tree.insertAfter(item.children ?? [], targetId, newItem),
};
}
return item;
});
},
insertChild(data: ProjectItem[], targetId: string, newItem: ProjectItem): ProjectItem[] {
return data.flatMap((item) => {
if (getPath(item) === targetId) {
// already a parent: add as first child
return {
...item,
// opening item so you can see where item landed
isOpen: true,
children: [newItem, ...item.children ?? []],
};
}
if (!tree.hasChildren(item))
return item;
return {
...item,
children: tree.insertChild(item.children ?? [], targetId, newItem),
};
});
},
find(data: ProjectItem[], itemId: string): ProjectItem | undefined {
for (const item of data) {
if (getPath(item) === itemId)
return item;
if (tree.hasChildren(item)) {
const result = tree.find(item.children ?? [], itemId);
if (result)
return result;
}
}
},
getPathToItem({
current,
targetId,
parentIds = [],
}: {
current: ProjectItem[]
targetId: string
parentIds?: string[]
}): string[] | undefined {
for (const item of current) {
if (getPath(item) === targetId)
return parentIds;
const nested = tree.getPathToItem({
current: (item.children ?? []),
targetId,
parentIds: [...parentIds, getPath(item)],
});
if (nested)
return nested;
}
},
hasChildren(item: ProjectItem): boolean {
return (item.children ?? []).length > 0;
},
}
function updateTree(instruction: Instruction, itemId: string, targetId: string) : ProjectItem[] | undefined {
if(!navigation.value)
return;
const item = tree.find(navigation.value, itemId);
const target = tree.find(navigation.value, targetId);
if(!item)
return;
if (instruction.type === 'reparent') {
const path = tree.getPathToItem({
current: navigation.value,
targetId: targetId,
});
if (!path) {
console.error(`missing ${path}`);
return;
}
const desiredId = path[instruction.desiredLevel];
let result = tree.remove(navigation.value, itemId);
result = tree.insertAfter(result, desiredId, item);
return result;
}
// the rest of the actions require you to drop on something else
if (itemId === targetId)
return navigation.value;
if (instruction.type === 'reorder-above') {
let result = tree.remove(navigation.value, itemId);
result = tree.insertBefore(result, targetId, item);
return result;
}
if (instruction.type === 'reorder-below') {
let result = tree.remove(navigation.value, itemId);
result = tree.insertAfter(result, targetId, item);
return result;
}
if (instruction.type === 'make-child') {
if(!target || target.type !== 'folder')
return;
let result = tree.remove(navigation.value, itemId);
result = tree.insertChild(result, targetId, item);
rebuildPath([item], targetId);
return result;
}
return navigation.value;
}
function drop(instruction: Instruction, itemId: string, targetId: string)
{
navigation.value = updateTree(instruction, itemId, targetId) ?? navigation.value ?? [];
}
function rebuildPath(tree: ProjectItem[] | null | undefined, parentPath: string)
{
debugger;
if(!tree)
return;
tree.forEach(e => {
e.parent = parentPath;
rebuildPath(e.children, getPath(e));
});
}
async function save(redirect: boolean): Promise<void>
{
saveStatus.value = 'pending';
try {
await $fetch(`/api/project`, {
method: 'post',
body: project.value,
});
saveStatus.value = 'success';
toaster.clear('error');
toaster.add({
type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000
});
if(redirect) router.push({ name: 'explore-path', params: { path: path.value } });
} catch(e: any) {
toaster.add({
type: 'error', content: e.message, timer: true, duration: 10000
})
saveStatus.value = 'error';
}
}
function getPath(item: ProjectItem): string
{
return [item.parent, parsePath(item?.name ?? item.title)].filter(e => !!e).join('/');
}
</script>

View File

@ -1,3 +1,42 @@
<template> <template>
Index <div v-if="status === 'pending'" class="flex">
<Head>
<Title>d[any] - Chargement</Title>
</Head>
<Loading />
</div>
<div class="flex flex-1 justify-start items-start" v-else-if="page">
<Head>
<Title>d[any] - Accueil</Title>
</Head>
<div class="flex flex-1 justify-start items-start flex-col xl:px-24 md:px-8 px-4 py-6">
<div class="flex flex-1 flex-row justify-between items-center">
<ProseH1>{{ page.title }}</ProseH1>
<div class="flex gap-4">
<NuxtLink :href="{ name: 'explore-edit-path', params: { path: 'index' } }"><Button v-if="isOwner">Modifier la page</Button></NuxtLink>
<NuxtLink :href="{ name: 'explore-edit' }"><Button v-if="isOwner">Configurer le projet</Button></NuxtLink>
</div>
</div>
<Markdown :content="page.content" />
</div>
</div>
<div v-else-if="status === 'error'">
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<span>{{ error?.message }}</span>
</div>
<div v-else>
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<div><ProseH2>Impossible d'afficher le contenu demandé</ProseH2></div>
</div>
</template> </template>
<script setup lang="ts">
const { loggedIn, user } = useUserSession();
const { data: page, status, error } = await useFetch(`/api/file/index`);
const isOwner = computed(() => user.value?.id === page.value?.owner);
</script>

View File

@ -1,14 +1,3 @@
<script setup lang="ts">
const open = ref(false), username = ref(""), price = ref(750), disabled = ref(false), loading = ref(false);
watch(loading, (value) => {
if(value)
{
setTimeout(() => { open.value = true; loading.value = false }, 1500);
}
})
</script>
<template> <template>
<Head> <Head>
<Title>d[any] - Accueil</Title> <Title>d[any] - Accueil</Title>

View File

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

48
pages/roadmap.vue Normal file
View File

@ -0,0 +1,48 @@
<template>
<Head>
<Title>d[any] - Roadmap</Title>
</Head>
<div class="flex flex-col justify-start p-6">
<ProseH2>Roadmap</ProseH2>
<div class="grid grid-cols-4 gap-x-2 gap-y-4">
<div class="flex flex-col gap-2 justify-start">
<ProseH3>Administration</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Statistiques de consultation</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Statistiques de connexion</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Dashboard de statistiques</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Gestion de droits</span><ProseTag>prioritaire</ProseTag></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Synchro project <-> GIT</span><ProseTag>prioritaire</ProseTag></Label>
</div>
<div class="flex flex-col gap-2 justify-start">
<ProseH3>Editeur</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition de page</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition riche de page</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Edition live de page</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Raccourcis d'edition</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Affichage alternatif par page</span></Label>
</div>
<div class="flex flex-col gap-2 justify-start">
<ProseH3>Projet</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition du projet</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Déplacement des fichiers</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Configuration de droit du projet</span><ProseTag>prioritaire</ProseTag></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Theme par projet</span></Label>
</div>
<div class="flex flex-col gap-2 justify-start">
<ProseH3>Nouvelles features</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Historique des modifs</span><ProseTag>prioritaire</ProseTag></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Commentaire par page</span></Label>
</div>
<div class="flex flex-col gap-2 justify-start">
<ProseH3>Utilisateur</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Validation du compte par mail<ProseTag>prioritaire</ProseTag></span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Modification de profil</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Image de profil</span></Label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
</script>

View File

@ -1,6 +1,6 @@
<template> <template>
<Head> <Head>
<Title>Connexion</Title> <Title>d[any] - Connexion</Title>
</Head> </Head>
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center"> <div class="flex gap-8 items-center">

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { hasPermissions } from "#shared/auth.util";
definePageMeta({ definePageMeta({
guestsGoesTo: '/user/login', guestsGoesTo: '/user/login',
}) })
@ -16,7 +18,7 @@ async function deleteUser()
<template> <template>
<Head> <Head>
<Title>Mon profil</Title> <Title>d[any] - Mon profil</Title>
</Head> </Head>
<div class="grid lg:grid-cols-4 grid-col-2 w-full items-start py-8 gap-6 content-start" v-if="user"> <div class="grid lg:grid-cols-4 grid-col-2 w-full items-start py-8 gap-6 content-start" v-if="user">
<div class="flex flex-col gap-4 col-span-4 lg:col-span-3 border border-light-35 dark:border-dark-35 p-4"> <div class="flex flex-col gap-4 col-span-4 lg:col-span-3 border border-light-35 dark:border-dark-35 p-4">

View File

@ -1,6 +1,6 @@
<template> <template>
<Head> <Head>
<Title>Inscription</Title> <Title>d[any] - Inscription</Title>
</Head> </Head>
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center"> <div class="flex gap-8 items-center">

View File

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

15
schemas/navigation.ts Normal file
View File

@ -0,0 +1,15 @@
import { z } from "zod";
export const single = z.object({
path: z.string(),
owner: z.number().finite(),
title: z.string(),
type: z.enum(['folder', 'file', 'markdown', 'canvas']),
navigable: z.boolean(),
private: z.boolean(),
order: z.number().finite(),
});
export const table = z.array(single);
export type Navigation = z.infer<typeof table>;
export type NavigationItem = z.infer<typeof single>;

23
schemas/project.ts Normal file
View File

@ -0,0 +1,23 @@
import { z } from "zod";
const baseItem = z.object({
path: z.string(),
parent: z.string(),
name: z.string().optional(),
title: z.string(),
type: z.enum(['folder', 'file', 'markdown', 'canvas']),
navigable: z.boolean(),
private: z.boolean(),
order: z.number().finite(),
});
export const item: z.ZodType<ProjectItem> = baseItem.extend({
children: z.lazy(() => item.array().optional()),
});
export const project = z.object({
items: z.array(item),
});
export type Project = z.infer<typeof project>;
export type ProjectItem = z.infer<typeof baseItem> & {
children?: ProjectItem[]
};

View File

@ -1,3 +1,5 @@
import { hasPermissions } from "#shared/auth.util";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const session = await getUserSession(e); const session = await getUserSession(e);

View File

@ -21,6 +21,7 @@ export default defineEventHandler(async (e) => {
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'), 'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
'navigable': explorerContentTable.navigable, 'navigable': explorerContentTable.navigable,
'private': explorerContentTable.private, 'private': explorerContentTable.private,
'order': explorerContentTable.order,
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path }); }).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
if(content !== undefined) if(content !== undefined)

View File

@ -1,21 +1,25 @@
import useDatabase from '~/composables/useDatabase'; import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema'; import { explorerContentTable } from '~/db/schema';
import type { Navigation } from '~/types/api'; import type { NavigationItem } from '~/schemas/navigation';
export type NavigationTreeItem = NavigationItem & { children?: NavigationTreeItem[] };
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e); const { user } = await getUserSession(e);
/*if(!user)
{
throw createError({ statusCode: 401, statusText: 'Unauthorized' });
}*/
const db = useDatabase(); const db = useDatabase();
const content = db.select({ path: explorerContentTable.path, title: explorerContentTable.title, type: explorerContentTable.type, private: explorerContentTable.private, navigable: explorerContentTable.navigable, owner: explorerContentTable.owner }).from(explorerContentTable).prepare().all() as (Navigation & { owner: number, navigable: boolean })[]; const content = db.select({
path: explorerContentTable.path,
type: explorerContentTable.type,
owner: explorerContentTable.owner,
title: explorerContentTable.title,
navigable: explorerContentTable.navigable,
private: explorerContentTable.private,
order: explorerContentTable.order,
}).from(explorerContentTable).prepare().all();
if(content.length > 0) if(content.length > 0)
{ {
const navigation: Navigation[] = []; const navigation: NavigationTreeItem[] = [];
for(const idx in content) for(const idx in content)
{ {
@ -43,9 +47,10 @@ export default defineEventHandler(async (e) => {
} }
setResponseStatus(e, 404); setResponseStatus(e, 404);
return;
}); });
function addChild(arr: Navigation[], e: Navigation): void function addChild(arr: NavigationTreeItem[], e: NavigationItem): void
{ {
const parent = arr.find(f => e.path.startsWith(f.path)); const parent = arr.find(f => e.path.startsWith(f.path));
@ -58,6 +63,11 @@ function addChild(arr: Navigation[], e: Navigation): void
} }
else else
{ {
arr.push({ title: e.title, path: e.path, type: e.type, private: e.private }); arr.push({ ...e });
arr.sort((a, b) => {
if(a.order !== b.order)
return a.order - b.order;
return a.title.localeCompare(b.title);
});
} }
} }

76
server/api/project.get.ts Normal file
View File

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

View File

@ -0,0 +1,62 @@
import { hasPermissions } from "#shared/auth.util";
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { project, type ProjectItem } from '~/schemas/project';
import { parsePath } from "#shared/general.utils";
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
if(!user || !hasPermissions(user.permissions, ['admin', 'editor']))
{
throw createError({ statusCode: 401, statusText: 'Unauthorized' });
}
const body = await readValidatedBody(e, project.safeParse);
if(!body.success)
{
throw body.error;
}
const items = buildOrder(body.data.items);
const db = useDatabase();
db.transaction((tx) => {
for(let i = 0; i < items.length; i++)
{
const item = items[i];
tx.insert(explorerContentTable).values({
path: item.path,
owner: user.id,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
content: Buffer.from('', 'utf-8'),
}).onConflictDoUpdate({
set: {
path: [item.parent, parsePath(item?.name ?? item.title)].filter(e => !!e).join('/'),
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
},
target: explorerContentTable.path,
}).run();
}
});
});
function buildOrder(items: ProjectItem[]): ProjectItem[]
{
items.forEach((e, i) => {
e.order = i;
if(e.children) e.children = buildOrder(e.children);
});
return items.flatMap(e => [e, ...(e.children ?? [])]);
}

39
server/tasks/pull.ts Normal file
View File

@ -0,0 +1,39 @@
import useDatabase from "~/composables/useDatabase";
import type { FileType } from '~/types/api';
import { explorerContentTable } from "~/db/schema";
import { eq, ne } from "drizzle-orm";
const typeMapping: Record<string, FileType> = {
".md": "markdown",
".canvas": "canvas"
};
export default defineTask({
meta: {
name: 'pull',
description: 'Pull the data from Git',
},
async run(event) {
try {
const tree = await $fetch('https://git.peaceultime.com/api/v1/repos/peaceultime/system-aspect/git/trees/master', {
method: 'get',
headers: {
accept: 'application/json',
},
params: {
recursive: true,
per_page: 1000,
}
}) as any;
const db = useDatabase();
const files = db.select().from(explorerContentTable).where(ne(explorerContentTable.type, 'folder')).all();
return { result: true };
}
catch(e)
{
return { result: false, error: e };
}
},
})

View File

@ -11,8 +11,8 @@ const typeMapping: Record<string, FileType> = {
export default defineTask({ export default defineTask({
meta: { meta: {
name: 'sync', name: 'push',
description: 'Synchronise the project with Obsidian', description: 'Push the data to Git',
}, },
async run(event) { async run(event) {
try { try {
@ -27,7 +27,6 @@ export default defineTask({
} }
}) as any; }) as any;
const files: typeof explorerContentTable.$inferInsert = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e: any) => { const files: typeof explorerContentTable.$inferInsert = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e: any) => {
if(e.type === 'tree') if(e.type === 'tree')
{ {
@ -42,7 +41,7 @@ export default defineTask({
content: null, content: null,
owner: '1', owner: '1',
navigable: true, navigable: true,
private: e.path.startsWith('98.Privé') private: e.path.startsWith('98.Privé'),
} }
} }
@ -64,71 +63,10 @@ export default defineTask({
} }
})); }));
/*let tags: Tag[] = [];
const tagFile = files.find(e => e.path === "tags");
if(tagFile)
{
const parsed = useMarkdown()(tagFile.content);
const titles = parsed.children.filter(e => e.type === 'element' && e.tagName.match(/h\d/));
for(let i = 0; i < titles.length; i++)
{
const start = titles[i].position?.start.offset ?? 0;
const end = titles.length === i + 1 ? tagFile.content.length : titles[i + 1].position.start.offset - 1;
tags.push({ tag: titles[i].properties.id, description: tagFile.content.substring(titles[i].position?.end.offset + 1, end) });
}
}*/
const db = useDatabase(); const db = useDatabase();
db.delete(explorerContentTable).run(); db.delete(explorerContentTable).run();
db.insert(explorerContentTable).values(files).run(); db.insert(explorerContentTable).values(files).run();
/*const oldFiles = db.prepare(`SELECT * FROM explorer_files WHERE project = ?1`).all('1') as File[];
const removeFiles = db.prepare(`DELETE FROM explorer_files WHERE project = ?1 AND path = ?2`);
db.transaction((data: File[]) => data.forEach(e => removeFiles.run('1', e.path)))(oldFiles.filter(e => !files.find(f => f.path = e.path)));
removeFiles.finalize();
const oldTags = db.prepare(`SELECT * FROM explorer_tags WHERE project = ?1`).all('1') as Tag[];
const removeTags = db.prepare(`DELETE FROM explorer_tags WHERE project = ?1 AND tag = ?2`);
db.transaction((data: Tag[]) => data.forEach(e => removeTags.run('1', e.tag)))(oldTags.filter(e => !tags.find(f => f.tag = e.tag)));
removeTags.finalize();
const insertFiles = db.prepare(`INSERT INTO explorer_files("project", "path", "owner", "title", "order", "type", "content") VALUES (1, $path, 1, $title, $order, $type, $content)`);
const updateFiles = db.prepare(`UPDATE explorer_files SET content = $content WHERE project = 1 AND path = $path`);
db.transaction((content) => {
for (const item of content) {
let order = item.order;
if (typeof order === 'string')
order = parseInt(item.order, 10);
if (isNaN(order))
order = 999;
if(oldFiles.find(e => item.path === e.path))
updateFiles.run({ $path: item.path, $content: item.content });
else
insertFiles.run({ $path: item.path, $title: item.title, $type: item.type, $content: item.content, $order: order });
}
})(files);
insertFiles.finalize();
updateFiles.finalize();
const insertTags = db.prepare(`INSERT INTO explorer_tags("project", "tag", "description") VALUES (1, $tag, $description)`);
const updateTags = db.prepare(`UPDATE explorer_tags SET description = $description WHERE project = 1 AND tag = $tag`);
db.transaction((content) => {
for (const item of content) {
if (oldTags.find(e => item.tag === e.tag))
updateTags.run({ $tag: item.tag, $description: item.description });
else
insertTags.run({ $tag: item.tag, $description: item.description });
}
})(tags);
insertTags.finalize();
updateTags.finalize();*/
useStorage('cache').clear(); useStorage('cache').clear();
return { result: true }; return { result: true };

View File

@ -2,6 +2,10 @@ export function unifySlug(slug: string | string[]): string
{ {
return (Array.isArray(slug) ? slug.join('/') : slug); return (Array.isArray(slug) ? slug.join('/') : slug);
} }
export function parsePath(path: string): string
{
return path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
export function parseId(id: string | undefined): string |undefined export function parseId(id: string | undefined): string |undefined
{ {
return id?.normalize('NFD')?.replace(/[\u0300-\u036f]/g, '')?.replace(/^\d\. */g, '')?.replace(/\s/g, "-")?.replace(/%/g, "-percent")?.replace(/\?/g, "-q")?.toLowerCase(); return id?.normalize('NFD')?.replace(/[\u0300-\u036f]/g, '')?.replace(/^\d\. */g, '')?.replace(/\s/g, "-")?.replace(/%/g, "-percent")?.replace(/\?/g, "-q")?.toLowerCase();