You've already forked obsidian-visualiser
Compare commits
30 Commits
rework
...
602b0af212
| Author | SHA1 | Date | |
|---|---|---|---|
| 602b0af212 | |||
| f7094f7ce1 | |||
| 429f1d4b38 | |||
| 5062d52667 | |||
| c4bf95e48b | |||
| 7fc7998a4b | |||
| fdaf765e2d | |||
| e99a5f15b4 | |||
| 5fb708051b | |||
| 9a69a92ef8 | |||
| f22e63bd4d | |||
| e83d8e802f | |||
| 3e463ea286 | |||
| 4125cbb3a2 | |||
| 4df9297d47 | |||
| d71e8b7910 | |||
| 20ab51a66c | |||
| 2855d4ba2e | |||
| 4f2fc31695 | |||
| 6e7243982b | |||
| 9c52494f8e | |||
| d0de943df2 | |||
| 1c239f161b | |||
| a9363e8c06 | |||
| d708e9ceb6 | |||
| 0c17dbf7bc | |||
| ac17134b7e | |||
| adb37b255a | |||
| b54402fc19 | |||
| 0882eb1dd0 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,3 +22,7 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
db.sqlite
|
||||||
|
db.sqlite-wal
|
||||||
|
db.sqlite-shm
|
||||||
1
app.vue
1
app.vue
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
|
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
|
||||||
<NuxtRouteAnnouncer/>
|
<NuxtRouteAnnouncer/>
|
||||||
|
<NuxtLoadingIndicator />
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<div class="xl:ps-12 xl:pe-12 ps-6 pe-4 flex flex-1 justify-center overflow-auto max-h-full relative">
|
<div class="xl:ps-12 xl:pe-12 ps-6 pe-4 flex flex-1 justify-center overflow-auto max-h-full relative">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<CollapsibleRoot v-model:open="model" :disabled="disabled">
|
<CollapsibleRoot v-model:open="model" :disabled="disabled" :defaultOpen="defaultOpen">
|
||||||
<div class="flex flex-row justify-center items-center">
|
<div class="flex flex-row justify-center items-center">
|
||||||
<span v-if="!!label">{{ label }}</span>
|
<span v-if="!!label">{{ label }}</span>
|
||||||
<CollapsibleTrigger class="ms-4" asChild>
|
<CollapsibleTrigger class="ms-4" asChild>
|
||||||
@@ -18,9 +18,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
const { label, disabled = false } = defineProps<{
|
const { label, disabled = false, defaultOpen = false } = defineProps<{
|
||||||
label?: string
|
label?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
defaultOpen?: boolean
|
||||||
}>();
|
}>();
|
||||||
const model = defineModel<boolean>();
|
const model = defineModel<boolean>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
80
components/base/DraggableTree.vue
Normal file
80
components/base/DraggableTree.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<TreeRoot v-bind="forward" v-slot="{ flattenItems }" class="list-none select-none 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>
|
||||||
140
components/base/DraggableTreeItem.vue
Normal file
140
components/base/DraggableTreeItem.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<TreeItem ref="el" v-bind="forward" v-slot="{ isExpanded, isSelected, isIndeterminate, handleToggle, handleSelect }">
|
||||||
|
<slot
|
||||||
|
:is-expanded="isExpanded"
|
||||||
|
:is-selected="isSelected"
|
||||||
|
:is-indeterminate="isIndeterminate"
|
||||||
|
:handle-select="handleSelect"
|
||||||
|
:handle-toggle="handleToggle"
|
||||||
|
:isDragging="isDragging"
|
||||||
|
:isDraggedOver="isDraggedOver"
|
||||||
|
/>
|
||||||
|
<div v-if="instruction">
|
||||||
|
<slot name="hint" :instruction="instruction" />
|
||||||
|
</div>
|
||||||
|
</TreeItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||||
|
import { useForwardPropsEmits, type FlattenedItem, type TreeItemEmits, type TreeItemProps } from 'radix-vue';
|
||||||
|
import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||||
|
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
|
||||||
|
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
|
||||||
|
|
||||||
|
const props = defineProps<TreeItemProps<T> & FlattenedItem<T>>();
|
||||||
|
const emits = defineEmits<TreeItemEmits<T>>();
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default: (props: {
|
||||||
|
isExpanded: boolean
|
||||||
|
isSelected: boolean
|
||||||
|
isIndeterminate: boolean | undefined
|
||||||
|
isDragging: boolean
|
||||||
|
isDraggedOver: boolean
|
||||||
|
handleToggle: () => void
|
||||||
|
handleSelect: () => void
|
||||||
|
}) => any,
|
||||||
|
hint: (props: {
|
||||||
|
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
|
||||||
|
}) => any,
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const forward = useForwardPropsEmits(props, emits);
|
||||||
|
|
||||||
|
const element = templateRef('el');
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const isDraggedOver = ref(false);
|
||||||
|
const isInitialExpanded = ref(false);
|
||||||
|
const instruction = ref<Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null>(null);
|
||||||
|
|
||||||
|
const mode = computed(() => {
|
||||||
|
if (props.hasChildren)
|
||||||
|
return 'expanded'
|
||||||
|
if (props.index + 1 === props.parentItem?.children?.length)
|
||||||
|
return 'last-in-group'
|
||||||
|
return 'standard'
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
const currentElement = unrefElement(element) as HTMLElement;
|
||||||
|
|
||||||
|
if (!currentElement)
|
||||||
|
return
|
||||||
|
|
||||||
|
const item = { ...props.value, level: props.level, id: props._id }
|
||||||
|
|
||||||
|
const expandItem = () => {
|
||||||
|
if (!element.value?.isExpanded) {
|
||||||
|
element.value?.handleToggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeItem = () => {
|
||||||
|
if (element.value?.isExpanded) {
|
||||||
|
element.value?.handleToggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dndFunction = combine(
|
||||||
|
draggable({
|
||||||
|
element: currentElement,
|
||||||
|
getInitialData: () => item,
|
||||||
|
onDragStart: () => {
|
||||||
|
isDragging.value = true
|
||||||
|
isInitialExpanded.value = element.value?.isExpanded ?? false
|
||||||
|
closeItem()
|
||||||
|
},
|
||||||
|
onDrop: () => {
|
||||||
|
isDragging.value = false
|
||||||
|
if (isInitialExpanded.value)
|
||||||
|
expandItem()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
dropTargetForElements({
|
||||||
|
element: currentElement,
|
||||||
|
getData: ({ input, element }) => {
|
||||||
|
const data = { id: item.id }
|
||||||
|
|
||||||
|
return attachInstruction(data, {
|
||||||
|
input,
|
||||||
|
element,
|
||||||
|
indentPerLevel: 16,
|
||||||
|
currentLevel: props.level,
|
||||||
|
mode: mode.value,
|
||||||
|
block: [],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
canDrop: ({ source }) => {
|
||||||
|
return source.data.id !== item.id
|
||||||
|
},
|
||||||
|
onDrag: ({ self }) => {
|
||||||
|
instruction.value = extractInstruction(self.data) as typeof instruction.value
|
||||||
|
},
|
||||||
|
onDragEnter: ({ source }) => {
|
||||||
|
if (source.data.id !== item.id) {
|
||||||
|
isDraggedOver.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragLeave: () => {
|
||||||
|
isDraggedOver.value = false
|
||||||
|
instruction.value = null
|
||||||
|
},
|
||||||
|
onDrop: ({ location }) => {
|
||||||
|
isDraggedOver.value = false
|
||||||
|
instruction.value = null
|
||||||
|
},
|
||||||
|
getIsSticky: () => true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
monitorForElements({
|
||||||
|
canMonitor: ({ source }) => {
|
||||||
|
return source.data.id !== item.id
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleanup dnd function
|
||||||
|
onCleanup(() => dndFunction())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
70
components/base/DropdownContentRender.vue
Normal file
70
components/base/DropdownContentRender.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<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" />
|
||||||
|
<div class="flex flex-1 justify-between">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<span v-if="item.kbd" class="mx-2 text-xs font-mono text-light-70 dark:text-dark-70 relative top-0.5"> {{ item.kbd }} </span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- TODO -->
|
||||||
|
<template v-if="item.type === 'radio'">
|
||||||
|
<DropdownMenuLabel>{{ item.label }}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup @update:model-value="item.change">
|
||||||
|
<DropdownMenuRadioItem v-for="option in item.items" :disabled="(option as any)?.disabled ?? false" :value="(option as any)?.value ?? option">
|
||||||
|
<DropdownMenuItemIndicator>
|
||||||
|
<Icon icon="radix-icons:dot-filled" />
|
||||||
|
</DropdownMenuItemIndicator>
|
||||||
|
<span>{{ (option as any)?.label || option }}</span>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator v-if="idx !== options.length - 1" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="item.type === 'submenu'">
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger class="group cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
|
||||||
|
<Icon v-if="item.icon" :icon="item.icon" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<Icon icon="radix-icons:chevron-right" class="absolute right-1" />
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
|
||||||
|
<DropdownContentRender :options="item.items" />
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="item.type === 'group'">
|
||||||
|
<DropdownMenuLabel class="text-light-70 dark:text-dark-70 text-sm text-center pt-1">{{ item.label }}</DropdownMenuLabel>
|
||||||
|
<DropdownContentRender :options="item.items" />
|
||||||
|
|
||||||
|
<DropdownMenuSeparator v-if="idx !== options.length - 1" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownOption } from './DropdownMenu.vue';
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
const { options } = defineProps<{
|
||||||
|
options: DropdownOption[]
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
58
components/base/DropdownMenu.vue
Normal file
58
components/base/DropdownMenu.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<DropdownMenuRoot>
|
||||||
|
<DropdownMenuTrigger :disabled="disabled" ><slot /></DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuContent :align="align" :side="side" class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
|
||||||
|
<DropdownContentRender :options="options" />
|
||||||
|
|
||||||
|
<DropdownMenuArrow class="fill-light-35 dark:fill-dark-35" />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface DropdownItem {
|
||||||
|
type: 'item';
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
select?: () => void;
|
||||||
|
icon?: string;
|
||||||
|
kbd?: string;
|
||||||
|
}
|
||||||
|
export interface DropdownCheckbox {
|
||||||
|
type: 'checkbox';
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
checked?: boolean | Ref<boolean>
|
||||||
|
select?: (state: boolean) => void;
|
||||||
|
kbd?: string;
|
||||||
|
}
|
||||||
|
export interface DropdownRadioGroup {
|
||||||
|
type: 'radio';
|
||||||
|
label: string;
|
||||||
|
value?: string | Ref<string>
|
||||||
|
items: (string | {label: string, value?: string, disabled?: boolean})[];
|
||||||
|
change?: (value: string) => void;
|
||||||
|
}
|
||||||
|
export interface DropdownSubmenu {
|
||||||
|
type: 'submenu';
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
items: DropdownOption[];
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
export interface DropdownGroup {
|
||||||
|
type: 'group';
|
||||||
|
label?: string;
|
||||||
|
items: DropdownOption[];
|
||||||
|
}
|
||||||
|
export type DropdownOption = DropdownItem | DropdownCheckbox | DropdownRadioGroup | DropdownSubmenu | DropdownGroup;
|
||||||
|
const { options, disabled = false, side, align } = defineProps<{
|
||||||
|
options: DropdownOption[]
|
||||||
|
disabled?: boolean
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
align?: 'start' | 'center' | 'end'
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<HoverCardRoot :open-delay="delay">
|
<HoverCardRoot :open-delay="delay" @update:open="(...args) => emits('open', ...args)">
|
||||||
<HoverCardTrigger class="inline-block cursor-help outline-none">
|
<HoverCardTrigger class="inline-block cursor-help outline-none">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
@@ -18,4 +18,6 @@ const { delay = 500, disabled = false, side = 'bottom' } = defineProps<{
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emits = defineEmits(['open'])
|
||||||
</script>
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Label class="py-4 flex flex-row justify-center items-center">
|
<Label class="my-2 flex flex-1 items-center justify-between flex-col md:flex-row">
|
||||||
<span>{{ label }}</span>
|
<span class="pb-1 md:p-0">{{ label }}</span>
|
||||||
<SelectRoot v-model="model">
|
<SelectRoot v-model="model">
|
||||||
<SelectTrigger :disabled="disabled" class="mx-4 inline-flex min-w-[160px] items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1
|
<SelectTrigger :disabled="disabled" class="mx-4 inline-flex min-w-[160px] items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1
|
||||||
bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none data-[placeholder]:font-normal
|
bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none data-[placeholder]:font-normal
|
||||||
|
|||||||
21
components/base/TagsInput.vue
Normal file
21
components/base/TagsInput.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<TagsInputRoot v-model="model" addOnPaste class="flex gap-2 items-center border p-2 w-full flex-wrap border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10" >
|
||||||
|
<TagsInputItem v-for="item in model" :key="item" :value="item" class="text-light-100 dark:text-dark-100 flex items-center justify-center gap-2 bg-light-20 dark:bg-dark-20 hover:bg-light-35 dark:hover:bg-dark-35 p-1 border border-light-35 dark:border-dark-35">
|
||||||
|
<TagsInputItemText class="text-sm pl-1" />
|
||||||
|
<TagsInputItemDelete asChild>
|
||||||
|
<Icon icon="radix-icons:cross-2" class="w-4 h-4 cursor-pointer" />
|
||||||
|
</TagsInputItemDelete>
|
||||||
|
</TagsInputItem>
|
||||||
|
|
||||||
|
<TagsInputInput :placeholder="placeholder" class="text-sm focus:outline-none flex-1 rounded text-green9 bg-transparent placeholder:text-mauve9 px-1" />
|
||||||
|
</TagsInputRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
const { placeholder } = defineProps<{
|
||||||
|
placeholder?: string
|
||||||
|
}>();
|
||||||
|
const model = defineModel<string[]>();
|
||||||
|
</script>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
class="mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50
|
class="mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50
|
||||||
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||||
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20"
|
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20"
|
||||||
:type="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs">
|
:type="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs" @change="(e) => emits('change', e)" @input="(e) => emits('input', e)">
|
||||||
</Label>
|
</Label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -16,5 +16,10 @@ const { type = 'text', label, disabled = false, placeholder } = defineProps<{
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
change: [Event]
|
||||||
|
input: [Event]
|
||||||
|
}>();
|
||||||
const model = defineModel<string>();
|
const model = defineModel<string>();
|
||||||
</script>
|
</script>
|
||||||
@@ -1,50 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm" :items="model" :get-key="(item) => item.link ?? item.label">
|
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 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">
|
<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">
|
<slot :isExpanded="isExpanded" :item="item" />
|
||||||
<Icon v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level - 1}em` }" />
|
|
||||||
<div class="pl-3 py-1 flex-1 truncate" :data-tag="item.value.tag">
|
|
||||||
{{ item.value.label }}
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</TreeItem>
|
</TreeItem>
|
||||||
</TreeRoot>
|
</TreeRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
const { getKey } = defineProps<{
|
||||||
|
getKey: (val: T) => string
|
||||||
|
}>();
|
||||||
|
|
||||||
interface TreeItem
|
const model = defineModel<T[]>();
|
||||||
{
|
|
||||||
label: string
|
|
||||||
link?: string
|
|
||||||
tag?: string
|
|
||||||
children?: TreeItem[]
|
|
||||||
}
|
|
||||||
const model = defineModel<TreeItem[]>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
function flatten(arr: T[]): string[]
|
||||||
[data-tag="canvas"]:after,
|
|
||||||
[data-tag="private"]:after
|
|
||||||
{
|
{
|
||||||
@apply text-sm;
|
return arr.filter(e => e.open).flatMap(e => [getKey(e), ...flatten(e.children ?? [])]);
|
||||||
@apply font-normal;
|
|
||||||
@apply float-end;
|
|
||||||
@apply border ;
|
|
||||||
@apply border-light-35 ;
|
|
||||||
@apply dark:border-dark-35;
|
|
||||||
@apply px-1;
|
|
||||||
@apply bg-light-20;
|
|
||||||
@apply dark:bg-dark-20;
|
|
||||||
font-variant: small-caps;
|
|
||||||
}
|
}
|
||||||
[data-tag="canvas"]:after
|
</script>
|
||||||
{
|
|
||||||
content: 'Canvas'
|
|
||||||
}
|
|
||||||
[data-tag="private"]:after
|
|
||||||
{
|
|
||||||
content: 'Privé'
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -150,24 +150,20 @@ dark:border-dark-purple
|
|||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const dragHandler = useDrag(({ event: Event, delta: [x, y] }: { event: Event, delta: number[] }) => {
|
const pinchHandler = usePinch(({ event, offset: [z] }: { event: Event, offset: number[] }) => {
|
||||||
event?.preventDefault();
|
zoom.value = clamp(z / 2048, minZoom.value, 3);
|
||||||
|
}, {
|
||||||
|
domTarget: canvas,
|
||||||
|
eventOptions: { passive: false, }
|
||||||
|
})
|
||||||
|
const dragHandler = useDrag(({ event, delta: [x, y] }: { event: Event, delta: number[] }) => {
|
||||||
dispX.value += x / zoom.value;
|
dispX.value += x / zoom.value;
|
||||||
dispY.value += y / zoom.value;
|
dispY.value += y / zoom.value;
|
||||||
}, {
|
}, {
|
||||||
domTarget: canvas,
|
domTarget: canvas,
|
||||||
eventOptions: { passive: false, }
|
eventOptions: { passive: false, }
|
||||||
})
|
})
|
||||||
const pinchHandler = usePinch(({ event: Event, offset: [z] }: { event: Event, offset: number[] }) => {
|
const wheelHandler = useWheel(({ event, delta: [x, y] }: { event: Event, delta: number[] }) => {
|
||||||
event?.preventDefault();
|
|
||||||
console.log(z);
|
|
||||||
zoom.value = clamp(z / 2048, minZoom.value, 3);
|
|
||||||
}, {
|
|
||||||
domTarget: canvas,
|
|
||||||
eventOptions: { passive: false, }
|
|
||||||
})
|
|
||||||
const wheelHandler = useWheel(({ event: Event, delta: [x, y] }: { event: Event, delta: number[] }) => {
|
|
||||||
event?.preventDefault();
|
|
||||||
zoom.value = clamp(zoom.value + y * -0.001, minZoom.value, 3);
|
zoom.value = clamp(zoom.value + y * -0.001, minZoom.value, 3);
|
||||||
}, {
|
}, {
|
||||||
domTarget: canvas,
|
domTarget: canvas,
|
||||||
@@ -180,7 +176,7 @@ const wheelHandler = useWheel(({ event: Event, delta: [x, y] }: { event: Event,
|
|||||||
<template #default>
|
<template #default>
|
||||||
<div id="canvas" ref="canvas" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none"
|
<div id="canvas" ref="canvas" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none"
|
||||||
:style="{ '--zoom-multiplier': (1 / Math.pow(zoom, 0.7)) }">
|
:style="{ '--zoom-multiplier': (1 / Math.pow(zoom, 0.7)) }">
|
||||||
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 absolute sm:top-2 top-10 left-2 z-30 overflow-hidden">
|
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden">
|
||||||
<Tooltip message="Zoom avant" side="right">
|
<Tooltip message="Zoom avant" side="right">
|
||||||
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3)" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3)" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||||
<Icon icon="radix-icons:plus" />
|
<Icon icon="radix-icons:plus" />
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLink no-prefetch class="text-accent-blue inline-flex items-center" v-if="data && data[0]"
|
<NuxtLink class="text-accent-blue inline-flex items-center" v-if="overview"
|
||||||
:to="{ name: 'explore-path', params: { path: data[0].path }, hash: hash }" :class="class">
|
:to="{ name: 'explore-path', params: { path: overview.path }, hash: hash }" :class="class">
|
||||||
<HoverCard class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': data[0].type === 'canvas'}">
|
<HoverCard class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview.type === 'canvas'}" @open="load">
|
||||||
<template #content>
|
<template #content>
|
||||||
<template v-if="data[0].type === 'markdown'">
|
<template v-if="loading">
|
||||||
|
<Loading />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="overview.type === 'markdown'">
|
||||||
<div class="px-10">
|
<div class="px-10">
|
||||||
<Markdown :content="data[0].content" />
|
<Markdown :content="content!.content" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="data[0].type === 'canvas'">
|
<template v-else-if="overview.type === 'canvas'">
|
||||||
<div class="w-[600px] h-[600px] relative">
|
<div class="w-[600px] h-[600px] relative">
|
||||||
<Canvas :canvas="JSON.parse(data[0].content)" />
|
<Canvas :canvas="JSON.parse(content!.content)" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #default>
|
<template #default>
|
||||||
<slot v-bind="$attrs"></slot>
|
<slot v-bind="$attrs"></slot>
|
||||||
<Icon class="w-4 h-4 inline-block" v-if="data && data[0] && data[0].type !== 'markdown'" :icon="iconByType[data[0].type]" />
|
<Icon class="w-4 h-4 inline-block" v-if="overview && overview.type !== 'markdown'" :icon="iconByType[overview.type]" />
|
||||||
</template>
|
</template>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink no-prefetch v-else-if="href" :to="href" :class="class" class="text-accent-blue inline-flex items-center">
|
<NuxtLink v-else-if="href" :to="href" :class="class" class="text-accent-blue inline-flex items-center">
|
||||||
<slot v-bind="$attrs"></slot>
|
<slot v-bind="$attrs"></slot>
|
||||||
<Icon class="w-4 h-4 inline-block" v-if="data && data[0] && data[0].type !== 'markdown'" :height="20" :width="20"
|
<Icon class="w-4 h-4 inline-block" v-if="overview && overview.type !== 'markdown'" :height="20" :width="20" :icon="iconByType[overview.type]" />
|
||||||
:icon="`icons/link-${data[0].type.toLowerCase()}`" />
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<slot :class="class" v-else v-bind="$attrs"></slot>
|
<slot :class="class" v-else v-bind="$attrs"></slot>
|
||||||
</template>
|
</template>
|
||||||
@@ -31,29 +33,43 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { parseURL } from 'ufo';
|
import { parseURL } from 'ufo';
|
||||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
import { iconByType } from '#shared/general.utils';
|
||||||
|
|
||||||
const iconByType: Record<string, string> = {
|
|
||||||
'folder': 'circum:folder-on',
|
|
||||||
'canvas': 'ph:graph-light',
|
|
||||||
'file': 'radix-icons:file',
|
|
||||||
}
|
|
||||||
const { href } = defineProps<{
|
const { href } = defineProps<{
|
||||||
href: string
|
href: string
|
||||||
class?: string
|
class?: string
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { hash, pathname, protocol } = parseURL(href);
|
const { hash, pathname, protocol } = parseURL(href);
|
||||||
const data = ref(), loading = ref(false);
|
const overview = ref<{
|
||||||
|
path: string;
|
||||||
|
owner: number;
|
||||||
|
title: string;
|
||||||
|
type: "file" | "folder" | "markdown" | "canvas";
|
||||||
|
navigable: boolean;
|
||||||
|
private: boolean;
|
||||||
|
order: number;
|
||||||
|
visit: number;
|
||||||
|
}>(), content = ref<{
|
||||||
|
content: string;
|
||||||
|
}>(), loading = ref(false), fetched = ref(false);
|
||||||
|
|
||||||
if(!!pathname && !protocol)
|
if(!!pathname && !protocol)
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
|
overview.value = await $fetch(`/api/file/overview/${encodeURIComponent(pathname)}`);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load()
|
||||||
|
{
|
||||||
|
if(fetched.value === true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
fetched.value = true;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
data.value = await $fetch(`/api/file`, {
|
content.value = await $fetch(`/api/file/content/${encodeURIComponent(pathname)}`);
|
||||||
query: {
|
|
||||||
search: `%${pathname}`
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch(e) { }
|
} catch(e) { }
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
|
||||||
<slot></slot>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</HoverPopup> -->
|
|
||||||
<span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30">
|
<span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- <script setup lang="ts">
|
|
||||||
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> -->
|
|
||||||
@@ -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
191
composables/useShortcuts.ts
Normal 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-shm
BIN
db.sqlite-shm
Binary file not shown.
BIN
db.sqlite-wal
BIN
db.sqlite-wal
Binary file not shown.
@@ -12,6 +12,8 @@ export const usersTable = sqliteTable("users", {
|
|||||||
export const usersDataTable = sqliteTable("users_data", {
|
export const usersDataTable = sqliteTable("users_data", {
|
||||||
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||||
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
lastTimestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
logCount: int().notNull().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userSessionsTable = sqliteTable("user_sessions", {
|
export const userSessionsTable = sqliteTable("user_sessions", {
|
||||||
@@ -39,8 +41,11 @@ 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().notNull(),
|
||||||
|
visit: int().notNull().default(0),
|
||||||
|
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const usersRelation = relations(usersTable, ({ one, many }) => ({
|
export const usersRelation = relations(usersTable, ({ one, many }) => ({
|
||||||
|
|||||||
2
drizzle/0003_cultured_skaar.sql
Normal file
2
drizzle/0003_cultured_skaar.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `explorer_content` ADD `order` integer;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `order` ON `explorer_content` (`order`);
|
||||||
21
drizzle/0004_ancient_thunderball.sql
Normal file
21
drizzle/0004_ancient_thunderball.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_explorer_content` (
|
||||||
|
`path` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner` integer NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`content` blob,
|
||||||
|
`navigable` integer DEFAULT true NOT NULL,
|
||||||
|
`private` integer DEFAULT false NOT NULL,
|
||||||
|
`order` integer NOT NULL,
|
||||||
|
`visit` integer DEFAULT 0 NOT NULL,
|
||||||
|
`timestamp` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`owner`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_explorer_content`("path", "owner", "title", "type", "content", "navigable", "private", "order", "visit", "timestamp") SELECT "path", "owner", "title", "type", "content", "navigable", "private", "order", "visit", "timestamp" FROM `explorer_content`;--> statement-breakpoint
|
||||||
|
DROP TABLE `explorer_content`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_explorer_content` RENAME TO `explorer_content`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users_data` ADD `lastTimestamp` integer NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users_data` ADD `logCount` integer DEFAULT 0 NOT NULL;
|
||||||
313
drizzle/meta/0003_snapshot.json
Normal file
313
drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "a1a7b478-d0c3-4fc6-b74a-1a010c1d8ca1",
|
||||||
|
"prevId": "6da7ff20-0db8-4055-a353-bb0ea2fa5e0b",
|
||||||
|
"tables": {
|
||||||
|
"explorer_content": {
|
||||||
|
"name": "explorer_content",
|
||||||
|
"columns": {
|
||||||
|
"path": {
|
||||||
|
"name": "path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"name": "owner",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"navigable": {
|
||||||
|
"name": "navigable",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"private": {
|
||||||
|
"name": "private",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"name": "order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"order": {
|
||||||
|
"name": "order",
|
||||||
|
"columns": [
|
||||||
|
"order"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"explorer_content_owner_users_id_fk": {
|
||||||
|
"name": "explorer_content_owner_users_id_fk",
|
||||||
|
"tableFrom": "explorer_content",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_permissions": {
|
||||||
|
"name": "user_permissions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"name": "permission",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_permissions_id_users_id_fk": {
|
||||||
|
"name": "user_permissions_id_users_id_fk",
|
||||||
|
"tableFrom": "user_permissions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"user_permissions_id_permission_pk": {
|
||||||
|
"columns": [
|
||||||
|
"id",
|
||||||
|
"permission"
|
||||||
|
],
|
||||||
|
"name": "user_permissions_id_permission_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_sessions": {
|
||||||
|
"name": "user_sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"name": "timestamp",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_sessions_user_id_users_id_fk": {
|
||||||
|
"name": "user_sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"user_sessions_id_user_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"id",
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"name": "user_sessions_id_user_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users_data": {
|
||||||
|
"name": "users_data",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"signin": {
|
||||||
|
"name": "signin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"users_data_id_users_id_fk": {
|
||||||
|
"name": "users_data_id_users_id_fk",
|
||||||
|
"tableFrom": "users_data",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"hash": {
|
||||||
|
"name": "hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"users_hash_unique": {
|
||||||
|
"name": "users_hash_unique",
|
||||||
|
"columns": [
|
||||||
|
"hash"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
335
drizzle/meta/0004_snapshot.json
Normal file
335
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "b6acf5d6-d8df-4308-8d4d-55c25741cc4f",
|
||||||
|
"prevId": "a1a7b478-d0c3-4fc6-b74a-1a010c1d8ca1",
|
||||||
|
"tables": {
|
||||||
|
"explorer_content": {
|
||||||
|
"name": "explorer_content",
|
||||||
|
"columns": {
|
||||||
|
"path": {
|
||||||
|
"name": "path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"name": "owner",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"navigable": {
|
||||||
|
"name": "navigable",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"private": {
|
||||||
|
"name": "private",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"name": "order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"visit": {
|
||||||
|
"name": "visit",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"name": "timestamp",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"explorer_content_owner_users_id_fk": {
|
||||||
|
"name": "explorer_content_owner_users_id_fk",
|
||||||
|
"tableFrom": "explorer_content",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_permissions": {
|
||||||
|
"name": "user_permissions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"name": "permission",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_permissions_id_users_id_fk": {
|
||||||
|
"name": "user_permissions_id_users_id_fk",
|
||||||
|
"tableFrom": "user_permissions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"user_permissions_id_permission_pk": {
|
||||||
|
"columns": [
|
||||||
|
"id",
|
||||||
|
"permission"
|
||||||
|
],
|
||||||
|
"name": "user_permissions_id_permission_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_sessions": {
|
||||||
|
"name": "user_sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"name": "timestamp",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_sessions_user_id_users_id_fk": {
|
||||||
|
"name": "user_sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"user_sessions_id_user_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"id",
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"name": "user_sessions_id_user_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users_data": {
|
||||||
|
"name": "users_data",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"signin": {
|
||||||
|
"name": "signin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lastTimestamp": {
|
||||||
|
"name": "lastTimestamp",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"logCount": {
|
||||||
|
"name": "logCount",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"users_data_id_users_id_fk": {
|
||||||
|
"name": "users_data_id_users_id_fk",
|
||||||
|
"tableFrom": "users_data",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"hash": {
|
||||||
|
"name": "hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"users_hash_unique": {
|
||||||
|
"name": "users_hash_unique",
|
||||||
|
"columns": [
|
||||||
|
"hash"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,20 @@
|
|||||||
"when": 1730985155814,
|
"when": 1730985155814,
|
||||||
"tag": "0002_messy_solo",
|
"tag": "0002_messy_solo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1731344368953,
|
||||||
|
"tag": "0003_cultured_skaar",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1732722840534,
|
||||||
|
"tag": "0004_ancient_thunderball",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Erreur {{ error?.statusCode }}</Title>
|
||||||
|
</Head>
|
||||||
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden justify-center items-center flex-col gap-4">
|
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden justify-center items-center flex-col gap-4">
|
||||||
<NuxtRouteAnnouncer/>
|
<NuxtRouteAnnouncer/>
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-4 items-center">
|
||||||
<Icon icon="si:error-line" class="w-12 h-12 text-light-60 dark:text-dark-60"/>
|
<Icon icon="si:error-line" class="w-12 h-12 text-light-60 dark:text-dark-60"/>
|
||||||
<div class="text-3xl">Une erreur est survenue.</div>
|
<div class="text-3xl">Une erreur est survenue.</div>
|
||||||
</div>
|
</div>
|
||||||
<pre class="">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
|
<pre class="text-center text-wrap">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
|
||||||
<NuxtLink :href="{ name: 'index' }"><Button>Revenir en lieu sûr</Button></NuxtLink>
|
<Button @click="handleError">Revenir en lieu sûr</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,17 @@
|
|||||||
</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">
|
||||||
|
<Icon :icon="'radix-icons:person'" class="w-7 h-7 p-1" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip v-else :message="'Mon profil'" side="right">
|
||||||
|
<DropdownMenu :options="options" side="bottom" align="end">
|
||||||
<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:avatar'" class="w-7 h-7 p-1" />
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</DropdownMenu>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,19 +37,42 @@
|
|||||||
</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="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>
|
||||||
</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-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">
|
||||||
|
<div class="pl-3 py-1 flex-1 truncate">Projet</div>
|
||||||
|
</NuxtLink>
|
||||||
|
<Tree v-if="pages" v-model="pages" :getKey="(item) => item.path">
|
||||||
|
<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` }" />
|
||||||
|
<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">
|
||||||
|
{{ item.value.title }}
|
||||||
|
</div>
|
||||||
|
<Tooltip message="Privé" side="right"><Icon v-show="item.value.private" icon="radix-icons:lock-closed" /></Tooltip>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</Tree>
|
||||||
|
</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: 'legal' }">Mentions légales</NuxtLink>
|
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
||||||
<p>Copyright Peaceultime - 2024</p>
|
<p>Copyright Peaceultime - 2024</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,20 +84,37 @@
|
|||||||
|
|
||||||
<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';
|
||||||
|
import { iconByType } from '#shared/general.utils';
|
||||||
|
import type { DropdownOption } from '~/components/base/DropdownMenu.vue';
|
||||||
|
|
||||||
const open = ref(true);
|
const options = ref<DropdownOption[]>([{
|
||||||
const { loggedIn } = useUserSession();
|
type: 'item',
|
||||||
|
label: 'Mon profil',
|
||||||
|
select: () => useRouter().push({ name: 'user-profile' }),
|
||||||
|
}, {
|
||||||
|
type: 'item',
|
||||||
|
label: 'Deconnexion',
|
||||||
|
select: () => clear(),
|
||||||
|
}]);
|
||||||
|
|
||||||
const { data: pages } = await useLazyFetch('/api/navigation', {
|
const open = ref(false);
|
||||||
transform: transform,
|
const { loggedIn, user, clear } = useUserSession();
|
||||||
});
|
|
||||||
|
|
||||||
watch(useRouter().currentRoute, () => {
|
const route = useRouter().currentRoute;
|
||||||
|
const path = computed(() => route.value.params.path ? Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path : undefined);
|
||||||
|
|
||||||
|
watch(route, () => {
|
||||||
open.value = false;
|
open.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function transform(list: any[]): any[]
|
const { data: pages } = await useLazyFetch('/api/navigation', {
|
||||||
|
transform: transform,
|
||||||
|
watch: [useRouter().currentRoute]
|
||||||
|
});
|
||||||
|
|
||||||
|
function transform(list: NavigationTreeItem[] | undefined): NavigationTreeItem[] | 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 => ({ ...e, open: path.value?.startsWith(e.path), children: transform(e.children) }));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
import vuePlugin from 'rollup-plugin-vue'
|
||||||
|
import postcssPlugin from 'rollup-plugin-postcss'
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2024-04-03',
|
compatibilityDate: '2024-04-03',
|
||||||
modules: [
|
modules: [
|
||||||
@@ -7,6 +10,7 @@ export default defineNuxtConfig({
|
|||||||
'@nuxtjs/tailwindcss',
|
'@nuxtjs/tailwindcss',
|
||||||
'@vueuse/nuxt',
|
'@vueuse/nuxt',
|
||||||
'radix-vue/nuxt',
|
'radix-vue/nuxt',
|
||||||
|
'@nuxtjs/sitemap',
|
||||||
],
|
],
|
||||||
tailwindcss: {
|
tailwindcss: {
|
||||||
viewer: false,
|
viewer: false,
|
||||||
@@ -114,17 +118,43 @@ export default defineNuxtConfig({
|
|||||||
path: '~/components',
|
path: '~/components',
|
||||||
pathPrefix: false,
|
pathPrefix: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '~/server/components',
|
||||||
|
pathPrefix: true,
|
||||||
|
global: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
nitro: {
|
nitro: {
|
||||||
|
alias: {
|
||||||
|
'public': '//public',
|
||||||
|
},
|
||||||
|
publicAssets: [{
|
||||||
|
baseURL: 'public',
|
||||||
|
dir: 'public',
|
||||||
|
}],
|
||||||
|
preset: 'bun',
|
||||||
experimental: {
|
experimental: {
|
||||||
tasks: true,
|
tasks: true,
|
||||||
},
|
},
|
||||||
|
rollupConfig: {
|
||||||
|
external: ['bun'],
|
||||||
|
plugins: [
|
||||||
|
vuePlugin({ include: /\.vue$/, target: 'node' })
|
||||||
|
]
|
||||||
|
},
|
||||||
},
|
},
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
session: {
|
session: {
|
||||||
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
|
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
|
||||||
},
|
},
|
||||||
database: 'db.sqlite'
|
database: 'db.sqlite',
|
||||||
|
mail: {
|
||||||
|
host: '',
|
||||||
|
port: '',
|
||||||
|
user: '',
|
||||||
|
passwd: '',
|
||||||
|
dkim: '',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
rateLimiter: false,
|
rateLimiter: false,
|
||||||
@@ -135,4 +165,18 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
xssValidator: false,
|
xssValidator: false,
|
||||||
},
|
},
|
||||||
|
sitemap: {
|
||||||
|
exclude: ['/admin/**', '/explore/edit/**', '/user/mailvalidated'],
|
||||||
|
sources: ['/api/__sitemap__/urls']
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
defaults: {
|
||||||
|
nuxtLink: {
|
||||||
|
prefetchOn: {
|
||||||
|
interaction: true,
|
||||||
|
visibility: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
26
package.json
26
package.json
@@ -7,23 +7,47 @@
|
|||||||
"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/sitemap": "^7.0.0",
|
||||||
"@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",
|
||||||
|
"nodemailer": "^6.9.16",
|
||||||
"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",
|
||||||
|
"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": "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/nodemailer": "^6.4.16",
|
||||||
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,255 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Format bytes as human-readable text.
|
||||||
|
*
|
||||||
|
* @param bytes Number of bytes.
|
||||||
|
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||||
|
* binary (IEC), aka powers of 1024.
|
||||||
|
* @param dp Number of decimal places to display.
|
||||||
|
*
|
||||||
|
* @return Formatted string.
|
||||||
|
*/
|
||||||
|
function textualFileSize(bytes: number, si: boolean = false, dp: number = 2) {
|
||||||
|
const thresh = si ? 1000 : 1024;
|
||||||
|
|
||||||
|
if (Math.abs(bytes) < thresh) {
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
let u = -1;
|
||||||
|
const r = 10**dp;
|
||||||
|
|
||||||
|
do {
|
||||||
|
bytes /= thresh;
|
||||||
|
++u;
|
||||||
|
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||||
|
|
||||||
|
|
||||||
|
return bytes.toFixed(dp) + ' ' + units[u];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { format, iconByType } from '~/shared/general.utils';
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
interface File
|
||||||
|
{
|
||||||
|
path: string;
|
||||||
|
owner: number;
|
||||||
|
title: string;
|
||||||
|
type: "file" | "canvas" | "markdown" | 'folder';
|
||||||
|
size: number;
|
||||||
|
navigable: boolean;
|
||||||
|
private: boolean;
|
||||||
|
order: number;
|
||||||
|
visit: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
interface User
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
state: number;
|
||||||
|
session: {
|
||||||
|
id: number;
|
||||||
|
}[];
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
signin: string;
|
||||||
|
lastTimestamp: string;
|
||||||
|
logCount: number;
|
||||||
|
};
|
||||||
|
permission: string[];
|
||||||
|
}
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
rights: ['admin'],
|
rights: ['admin'],
|
||||||
})
|
});
|
||||||
const job = ref<string>('');
|
|
||||||
|
|
||||||
const toaster = useToast();
|
const toaster = useToast();
|
||||||
const data = ref(), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), success = ref(false), err = ref(false), error = ref();
|
|
||||||
async function fetch()
|
|
||||||
{
|
|
||||||
status.value = 'pending';
|
|
||||||
data.value = null;
|
|
||||||
error.value = null;
|
|
||||||
err.value = false;
|
|
||||||
success.value = false;
|
|
||||||
|
|
||||||
|
const { data: users } = useFetch('/api/admin/users', {
|
||||||
|
transform: (users) => {
|
||||||
|
//@ts-ignore
|
||||||
|
users.forEach(e => e.permission = e.permission.map(p => p.permission));
|
||||||
|
//@ts-ignore
|
||||||
|
return users as User[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data: pages } = useFetch('/api/admin/pages');
|
||||||
|
|
||||||
|
const sorter = ref<((a: File, b: File) => number) | null>(null);
|
||||||
|
const sortField = ref<keyof File | null>(null), sortOrder = ref<null | 'asc' | 'desc'>('asc');
|
||||||
|
const sortedPage = ref([...pages.value ?? []]);
|
||||||
|
|
||||||
|
const permissionCopy = ref<string[]>([]);
|
||||||
|
|
||||||
|
watch([sortField, sortOrder, sorter], () => {
|
||||||
|
sortedPage.value = (sorter.value === null ? ([...pages.value ?? []]) : sortedPage.value.sort(sorter.value))
|
||||||
|
}, {
|
||||||
|
immediate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function sort(field: keyof File, type: 'string' | 'number')
|
||||||
|
{
|
||||||
|
if(sortField.value === field)
|
||||||
|
{
|
||||||
|
if(sortOrder.value === 'asc')
|
||||||
|
{
|
||||||
|
sortOrder.value = 'desc';
|
||||||
|
sorter.value = type === 'string' ? (a: File, b: File) => (b[field] as string).localeCompare(a[field] as string) : (a: File, b: File) => (b[field] as number) - (a[field] as number);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sortOrder.value = null;
|
||||||
|
sortField.value = null;
|
||||||
|
sorter.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sortField.value = field;
|
||||||
|
sortOrder.value = 'asc';
|
||||||
|
sorter.value = type === 'string' ? (a: File, b: File) => (a[field] as string).localeCompare(b[field] as string) : (a: File, b: File) => (a[field] as number) - (b[field] as number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function editPermissions(user: User)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
data.value = await $fetch(`/api/admin/jobs/${job.value}`, {
|
await $fetch(`/api/admin/user/${user.id}/permissions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
body: permissionCopy.value,
|
||||||
|
});
|
||||||
|
user.permission = permissionCopy.value;
|
||||||
|
toaster.add({
|
||||||
|
duration: 10000, type: 'success', content: 'Permissions mises à jour.', timer: true,
|
||||||
});
|
});
|
||||||
status.value = 'success';
|
|
||||||
error.value = null;
|
|
||||||
err.value = false;
|
|
||||||
success.value = true;
|
|
||||||
|
|
||||||
toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, });
|
|
||||||
}
|
}
|
||||||
catch(e)
|
catch(e)
|
||||||
{
|
{
|
||||||
status.value = 'error';
|
toaster.add({
|
||||||
error.value = e;
|
duration: 10000, type: 'error', content: (e as any).message, timer: true,
|
||||||
err.value = true;
|
});
|
||||||
success.value = false;
|
|
||||||
|
|
||||||
toaster.add({ duration: 10000, content: error.value, type: 'error', timer: true, });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head>
|
<Head>
|
||||||
<Title>Administration</Title>
|
<Title>d[any] - Administration</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<div class="flex flex-col justify-start">
|
<div class="flex flex-1 flex-col p-4">
|
||||||
<ProseH2>Administration</ProseH2>
|
<div class="flex flex-row justify-between items-center">
|
||||||
<Select label="Job" v-model="job">
|
<ProseH2 class="text-center flex-1">Administration</ProseH2>
|
||||||
<SelectItem label="Synchroniser" value="sync" />
|
<Button><NuxtLink :to="{ name: 'admin-jobs' }">Jobs</NuxtLink></Button>
|
||||||
<SelectItem label="Nettoyer la base" value="clear" disabled />
|
</div>
|
||||||
<SelectItem label="Reconstruire" value="rebuild" disabled />
|
<div class="flex flex-1 w-full justify-center items-stretch flex-row gap-4">
|
||||||
</Select>
|
<div class="flex-1">
|
||||||
<Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'">
|
<Collapsible v-if=users :label="`Utilisateurs (${users.length})`">
|
||||||
<span>Executer</span>
|
<div class="flex flex-1 mt-2">
|
||||||
</Button>
|
<table class="border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Utilisateur</th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Inscription</th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Dernière connexion</th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Mail</th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Sessions</th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Permissions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="font-normal">
|
||||||
|
<tr v-for="user in users">
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 max-w-32 truncate"><NuxtLink :to="{ name: 'user-id', params: { id: user.id } }" class="hover:text-accent-purple font-bold" :title="user.username">{{ user.username }}</NuxtLink></td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center">{{ format(new Date(user.data.signin), 'dd/MM/yyyy') }}</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center">{{ format(new Date(user.data.lastTimestamp), 'dd/MM/yyyy HH:mm:ss') }}</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center"><Icon :class="{ 'text-light-red dark:text-dark-red': user.state === 0, 'text-light-green dark:text-dark-green': user.state !== 0 }" :icon="user.state === 0 ? `radix-icons:cross-2` : `radix-icons:check`" /></td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1">
|
||||||
|
<DialogRoot>
|
||||||
|
<DialogTrigger asChild><span class="text-accent-blue hover:text-accent-purple font-bold cursor-pointer">{{ user.session.length }}</span></DialogTrigger>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||||
|
<DialogContent
|
||||||
|
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||||
|
<DialogTitle class="text-3xl font-light relative -top-2">Deconnecter l'utilisateur ?
|
||||||
|
</DialogTitle>
|
||||||
|
<div class="flex flex-1 justify-end gap-4">
|
||||||
|
<DialogClose asChild><Button>Non</Button></DialogClose>
|
||||||
|
<DialogClose asChild><Button class="border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green hover:bg-light-greenBack dark:hover:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green dark:focus:shadow-dark-green">Oui</Button></DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</DialogRoot>
|
||||||
|
</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1">
|
||||||
|
<AlertDialogRoot>
|
||||||
|
<AlertDialogTrigger asChild><span class="text-accent-blue hover:text-accent-purple font-bold" @click="permissionCopy = [...user.permission]">{{ user.permission.length }}</span></AlertDialogTrigger>
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||||
|
<AlertDialogContent
|
||||||
|
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||||
|
<AlertDialogTitle class="text-3xl font-light relative -top-2">Permissions de {{ user.username }}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription><TagsInput v-model="permissionCopy" /></AlertDialogDescription>
|
||||||
|
<div class="flex flex-1 justify-end gap-4">
|
||||||
|
<AlertDialogCancel asChild><Button>Annuler</Button></AlertDialogCancel>
|
||||||
|
<AlertDialogAction asChild><Button @click="() => editPermissions(user)" class="border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green hover:bg-light-greenBack dark:hover:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green dark:focus:shadow-dark-green">Modifier</Button></AlertDialogAction>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</AlertDialogRoot>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<Collapsible v-if=pages :label="`Pages (${pages.length})`">
|
||||||
|
<div class="flex flex-1 mt-2">
|
||||||
|
<table class="border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Page</span><span @click="() => sort('title', 'string')"><Icon :icon="sortField === 'title' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Type</span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Propriétaire</span><span @click="() => sort('owner', 'number')"><Icon :icon="sortField === 'owner' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Status</span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Taille</span><span @click="() => sort('size', 'number')"><Icon :icon="sortField === 'size' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Visites</span><span @click="() => sort('visit', 'number')"><Icon :icon="sortField === 'visit' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||||
|
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Actions</span></div></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="font-normal">
|
||||||
|
<DialogRoot>
|
||||||
|
<tr v-for="page in sortedPage" :id="page.path">
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 max-w-48 truncate"><NuxtLink :to="{ name: 'explore-path', params: { path: page.path } }" class="hover:text-accent-purple font-bold" :title="page.title">{{ page.title }}</NuxtLink></td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1"><Icon :icon="iconByType[page.type]" /></td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center max-w-32 truncate"><span :title=" users?.find(e => e.id === page.owner)?.username ?? 'Inconnu'">{{ users?.find(e => e.id === page.owner)?.username ?? "Inconnu" }}</span></td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 ">
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<span>
|
||||||
|
<Icon v-if="page.private" icon="radix-icons:lock-closed" />
|
||||||
|
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:lock-open-2" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Icon v-if="page.navigable" icon="radix-icons:eye-open" />
|
||||||
|
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:eye-none" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center">{{ textualFileSize(page.size) }}</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center">{{ page.visit }}</td>
|
||||||
|
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center"><div class="flex justify-center items-center"><NuxtLink :to="{ name: 'explore-edit-path', params: { path: page.path } }"><Icon icon="radix-icons:pencil-1" /></NuxtLink></div></td>
|
||||||
|
</tr>
|
||||||
|
</DialogRoot>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
93
pages/admin/jobs.vue
Normal file
93
pages/admin/jobs.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const mailSchema = z.object({
|
||||||
|
to: z.string().email(),
|
||||||
|
template: z.string(),
|
||||||
|
data: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaList: Record<string, z.ZodObject<any> | null> = {
|
||||||
|
'pull': null,
|
||||||
|
'push': null,
|
||||||
|
'mail': mailSchema,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
rights: ['admin'],
|
||||||
|
})
|
||||||
|
const job = ref<string>('');
|
||||||
|
|
||||||
|
const toaster = useToast();
|
||||||
|
const payload = reactive<Record<string, any>>({
|
||||||
|
data: JSON.stringify({ username: "Peaceultime", id: 1, timestamp: Date.now() }),
|
||||||
|
to: 'clem31470@gmail.com',
|
||||||
|
});
|
||||||
|
const data = ref(), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), success = ref(false), error = ref<Error | null>();
|
||||||
|
async function fetch()
|
||||||
|
{
|
||||||
|
status.value = 'pending';
|
||||||
|
data.value = null;
|
||||||
|
error.value = null;
|
||||||
|
success.value = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const schema = schemaList[job.value];
|
||||||
|
|
||||||
|
if(schema)
|
||||||
|
{
|
||||||
|
console.log(payload);
|
||||||
|
const parsedPayload = schema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.value = await $fetch(`/api/admin/jobs/${job.value}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
status.value = 'success';
|
||||||
|
error.value = null;
|
||||||
|
success.value = true;
|
||||||
|
|
||||||
|
toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, });
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
status.value = 'error';
|
||||||
|
error.value = e as Error;
|
||||||
|
success.value = false;
|
||||||
|
|
||||||
|
toaster.add({ duration: 10000, content: error.value.message, type: 'error', timer: true, });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Administration</Title>
|
||||||
|
</Head>
|
||||||
|
<div class="flex flex-col justify-start items-center p-4">
|
||||||
|
<div class="flex flex-row justify-between items-center gap-8">
|
||||||
|
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||||
|
<ProseH2 class="text-center flex-1">Administration</ProseH2>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row w-full gap-8">
|
||||||
|
<Select label="Job" v-model="job">
|
||||||
|
<SelectItem label="Récupérer les données d'Obsidian" value="pull" />
|
||||||
|
<SelectItem label="Envoyer les données dans Obsidian" value="push" disabled />
|
||||||
|
<SelectItem label="Envoyer un mail de test" value="mail" />
|
||||||
|
</Select>
|
||||||
|
<Select v-if="job === 'mail'" v-model="payload.template" label="Modèle" class="w-full" ><SelectItem label="Inscription" value="registration" /></Select>
|
||||||
|
</div>
|
||||||
|
<div v-if="job === 'mail'" class="flex justify-center items-center flex-col">
|
||||||
|
<TextInput label="Destinataire" class="w-full" v-model="payload.to" />
|
||||||
|
<textarea v-model="payload.data" class="w-[640px] bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none m-2 px-2"></textarea>
|
||||||
|
</div>
|
||||||
|
<Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'">
|
||||||
|
<span>Executer</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,35 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="status === 'pending'" class="flex">
|
<div v-if="overviewStatus === 'pending'" class="flex">
|
||||||
<Head>
|
<Head>
|
||||||
<Title>d[any] - Chargement</Title>
|
<Title>d[any] - Chargement</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<Loading />
|
<Loading />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 justify-start items-start" v-else-if="page">
|
<div class="flex flex-1 justify-start items-start" v-else-if="overview">
|
||||||
<Head>
|
<Head>
|
||||||
<Title>d[any] - {{ page.title }}</Title>
|
<Title>d[any] - {{ overview.title }}</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<template v-if="page.type === 'markdown'">
|
<template v-if="overview.type === 'markdown'">
|
||||||
<div class="flex flex-1 justify-start items-start flex-col xl:px-24 md:px-8 px-4 py-6">
|
<div class="flex flex-1 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">
|
<div class="flex flex-1 flex-row justify-between items-center">
|
||||||
<ProseH1>{{ page.title }}</ProseH1>
|
<ProseH1>{{ overview.title }}</ProseH1>
|
||||||
<NuxtLink :href="{ name: 'explore-edit-path', params: { path: path } }"><Button v-if="isOwner">Modifier</Button></NuxtLink>
|
<div class="flex gap-4">
|
||||||
|
<NuxtLink :href="{ name: 'explore-edit-path', params: { path: path } }" v-if="isOwner"><Button>Modifier</Button></NuxtLink>
|
||||||
|
<NuxtLink :href="{ name: 'explore-edit' }" v-if="isOwner && path === 'index'"><Button>Configurer le projet</Button></NuxtLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Markdown :content="page.content" />
|
<Markdown v-if="content" :content="content.content" />
|
||||||
|
<Loading v-else />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="page.type === 'canvas'">
|
<template v-else-if="overview.type === 'canvas'">
|
||||||
<Canvas :canvas="JSON.parse(page.content)" />
|
<Canvas v-if="content" :canvas="JSON.parse(content.content)" />
|
||||||
|
<Loading v-else />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ProseH2 class="flex-1 text-center">Impossible d'afficher le contenu demandé</ProseH2>
|
<ProseH2 class="flex-1 text-center">Impossible d'afficher le contenu demandé</ProseH2>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="status === 'error'">
|
<div v-else-if="overviewStatus === 'error'">
|
||||||
<Head>
|
<Head>
|
||||||
<Title>d[any] - Erreur</Title>
|
<Title>d[any] - Erreur</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<span>{{ error?.message }}</span>
|
<span>{{ overviewError?.message }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="contentStatus === 'error'">
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Erreur</Title>
|
||||||
|
</Head>
|
||||||
|
<span>{{ contentError?.message }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<Head>
|
<Head>
|
||||||
@@ -42,8 +53,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const route = useRouter().currentRoute;
|
const route = useRouter().currentRoute;
|
||||||
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
|
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
|
||||||
const { loggedIn, user } = useUserSession();
|
|
||||||
|
|
||||||
const { data: page, status, error } = await useFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [route, path], });
|
const { user } = useUserSession();
|
||||||
const isOwner = computed(() => user.value?.id === page.value?.owner);
|
|
||||||
|
const { data: overview, status: overviewStatus, error: overviewError } = await useFetch(`/api/file/overview/${encodeURIComponent(path.value)}`, { watch: [route, path], });
|
||||||
|
const { data: content, status: contentStatus, error: contentError } = await useFetch(`/api/file/content/${encodeURIComponent(path.value)}`, { watch: [route, path], });
|
||||||
|
const isOwner = computed(() => user.value?.id === overview.value?.owner);
|
||||||
</script>
|
</script>
|
||||||
@@ -1,83 +1,119 @@
|
|||||||
<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="overview" 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 {{ overview.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 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="overview.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">
|
<div class="flex gap-4 self-end xl:self-auto flex-wrap">
|
||||||
<Tooltip message="Consultable uniquement par le propriétaire" side="bottom"><Switch label="Privé" v-model="page.private" /></Tooltip>
|
<div class="flex gap-4">
|
||||||
<Tooltip message="Afficher dans le menu de navigation" side="bottom"><Switch label="Navigable" v-model="page.navigable" /></Tooltip>
|
<Tooltip message="Consultable uniquement par le propriétaire" side="bottom"><Switch label="Privé" v-model="overview.private" /></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>
|
<Tooltip message="Afficher dans le menu de navigation" side="bottom"><Switch label="Navigable" v-model="overview.navigable" /></Tooltip>
|
||||||
|
</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="overview.type === 'markdown'">
|
||||||
<SplitterGroup direction="horizontal" class="flex-1 w-full flex">
|
<Loading v-if="contentStatus === 'pending'" />
|
||||||
<SplitterPanel asChild>
|
<span v-else-if="contentError">{{ contentError.message }}</span>
|
||||||
<textarea v-model="page.content" class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto"></textarea>
|
<SplitterGroup direction="horizontal" class="flex-1 w-full flex" v-else >
|
||||||
|
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }" :defaultSize="50">
|
||||||
|
<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>
|
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }">
|
||||||
<div class="flex-1 max-h-full !overflow-y-auto px-8"><Markdown :content="debounced" :proses="{ 'a': FakeA }" /></div>
|
<div class="flex-1 max-h-full !overflow-y-auto px-8" :class="{ 'hidden': isCollapsed }"><Markdown :content="debounced" :proses="{ 'a': FakeA }" /></div>
|
||||||
</SplitterPanel>
|
</SplitterPanel>
|
||||||
</SplitterGroup>
|
</SplitterGroup>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="page.type === 'canvas'">
|
<template v-else-if="overview.type === 'canvas'">
|
||||||
<span class="flex-1 items-center"><ProseH1>Editeur de graphe en cours de développement</ProseH1></span>
|
<span class="flex-1 items-center"><ProseH1>Editeur de graphe en cours de développement</ProseH1></span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="page.type === 'file'">
|
<template v-else-if="overview.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) => console.log(e)" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="status === 'pending'" class="flex">
|
<div v-else-if="overviewStatus === 'pending'" class="flex">
|
||||||
<Head>
|
<Head>
|
||||||
<Title>Chargement</Title>
|
<Title>d[any] - Chargement</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<Loading />
|
<Loading />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="status === 'error'">{{ error?.message }}</div>
|
<div v-else-if="overviewStatus === 'error'">{{ overviewError?.message }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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: overview, status: overviewStatus, error: overviewError } = await useFetch(`/api/file/overview/${encodeURIComponent(path.value)}`, { watch: [ route, path ] });
|
||||||
|
const { data: page, status: contentStatus, error: contentError } = await useFetch(`/api/file/content/${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 || (overview.value && overview.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, ...overview.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
|
||||||
|
|||||||
407
pages/explore/edit/index.vue
Normal file
407
pages/explore/edit/index.vue
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
<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">
|
||||||
|
<div class="flex flex-1 flex-col w-[450px] max-w-[450px] max-h-full">
|
||||||
|
<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"
|
||||||
|
:items="navigation ?? undefined" :get-key="(item: Partial<ProjectExtendedItem>) => item.path !== undefined ? getPath(item as ProjectExtendedItem) : ''" @updateTree="drop">
|
||||||
|
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, item }">
|
||||||
|
<div class="flex flex-1 items-center 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>
|
||||||
|
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="group-[:hover]:text-accent-purple mx-2" @click="() => { handleSelect(); selected = isSelected ? undefined : item.value; }" />
|
||||||
|
<div class="pl-3 py-1 flex-1 truncate" :title="item.value.title" @click="() => { handleSelect(); selected = isSelected ? undefined : item.value; }">
|
||||||
|
{{ item.value.title }}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span @click="item.value.private = !item.value.private">
|
||||||
|
<Icon v-if="item.value.private" icon="radix-icons:lock-closed" />
|
||||||
|
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:lock-open-2" />
|
||||||
|
</span>
|
||||||
|
<span @click="item.value.navigable = !item.value.navigable">
|
||||||
|
<Icon v-if="item.value.navigable" icon="radix-icons:eye-open" />
|
||||||
|
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:eye-none" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #hint="{ instruction }">
|
||||||
|
<div v-if="instruction" class="absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50" :style="{
|
||||||
|
width: `calc(100% - ${instruction.currentLevel - 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>
|
||||||
|
<div class="flex flex-col flex-1">
|
||||||
|
<div class="flex self-end gap-4 px-4">
|
||||||
|
<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>Nouveau</Button>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button @click="navigation = tree.remove(navigation, getPath(selected))" v-if="selected" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer</Button>
|
||||||
|
<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" @change="(e) => {
|
||||||
|
if(selected && !selected.customPath)
|
||||||
|
{
|
||||||
|
selected.name = parsePath(selected.title);
|
||||||
|
rebuildPath(selected.children, getPath(selected));
|
||||||
|
}
|
||||||
|
}" 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 ms-6 flex-col justify-start items-start gap-2">
|
||||||
|
<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 items-center">/{{ selected.parent !== '' ? selected.parent + '/' : '' }}<TextInput v-model="selected.name" @input="(e) => {
|
||||||
|
if(selected && selected.customPath)
|
||||||
|
{
|
||||||
|
selected.name = parsePath(selected.name);
|
||||||
|
rebuildPath(selected.children, getPath(selected));
|
||||||
|
}
|
||||||
|
}" class="mx-0"/></pre>
|
||||||
|
<pre v-else>/{{ getPath(selected) }}</pre>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<HoverCard class="!py-2 !px-4"><Icon icon="radix-icons:question-mark-circled" /><template #content><span class="text-sm italic text-light-60 dark:text-dark-60">Un fichier privé n'est consultable que par le propriétaire du projet. Rendre un dossier privé cache automatiquement son contenu sans avoir à chaque fichier un par un.</span></template></HoverCard>
|
||||||
|
<Switch label="Privé" v-model="selected.private" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<HoverCard class="!py-2 !px-4"><Icon icon="radix-icons:question-mark-circled" /><template #content><span class="text-sm italic text-light-60 dark:text-dark-60">Un fichier navigable est disponible dans le menu de navigation à gauche. Les fichiers non navigable peuvent toujours être utilisés dans des liens.</span></template></HoverCard>
|
||||||
|
<Switch label="Navigable" v-model="selected.navigable" />
|
||||||
|
</div>
|
||||||
|
</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';
|
||||||
|
import type { FileType } from '~/schemas/file';
|
||||||
|
import { iconByType } from '#shared/general.utils';
|
||||||
|
|
||||||
|
interface ProjectExtendedItem extends ProjectItem
|
||||||
|
{
|
||||||
|
customPath: boolean
|
||||||
|
children?: ProjectExtendedItem[]
|
||||||
|
}
|
||||||
|
interface ProjectExtended
|
||||||
|
{
|
||||||
|
items: ProjectExtendedItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
rights: ['admin', 'editor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const toaster = useToast();
|
||||||
|
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
const { data: project } = await useFetch(`/api/project`, {
|
||||||
|
transform: (project) =>{
|
||||||
|
if(project)
|
||||||
|
(project as ProjectExtended).items = transform(project.items)!;
|
||||||
|
|
||||||
|
return project as ProjectExtended;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const navigation = computed<ProjectExtendedItem[] | undefined>({
|
||||||
|
get: () => project.value?.items,
|
||||||
|
set: (value) => {
|
||||||
|
const proj = project.value;
|
||||||
|
|
||||||
|
if(proj && value)
|
||||||
|
proj.items = value;
|
||||||
|
|
||||||
|
project.value = proj;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const selected = ref<ProjectExtendedItem>();
|
||||||
|
|
||||||
|
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' }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const tree = {
|
||||||
|
remove(data: ProjectExtendedItem[], id: string): ProjectExtendedItem[] {
|
||||||
|
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: ProjectExtendedItem[], targetId: string, newItem: ProjectExtendedItem): ProjectExtendedItem[] {
|
||||||
|
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: ProjectExtendedItem[], targetId: string, newItem: ProjectExtendedItem): ProjectExtendedItem[] {
|
||||||
|
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: ProjectExtendedItem[], targetId: string, newItem: ProjectExtendedItem): ProjectExtendedItem[] {
|
||||||
|
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: ProjectExtendedItem[], itemId: string): ProjectExtendedItem | undefined {
|
||||||
|
for (const item of data) {
|
||||||
|
if (getPath(item) === itemId)
|
||||||
|
return item;
|
||||||
|
|
||||||
|
if (tree.hasChildren(item)) {
|
||||||
|
const result = tree.find(item.children ?? [], itemId);
|
||||||
|
if (result)
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
search(data: ProjectExtendedItem[], prop: keyof ProjectExtendedItem, value: string): ProjectExtendedItem[] {
|
||||||
|
const arr = [];
|
||||||
|
|
||||||
|
for (const item of data)
|
||||||
|
{
|
||||||
|
if (item[prop]?.toString().toLowerCase()?.startsWith(value.toLowerCase()))
|
||||||
|
arr.push(item);
|
||||||
|
|
||||||
|
if (tree.hasChildren(item)) {
|
||||||
|
arr.push(...tree.search(item.children ?? [], prop, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr;
|
||||||
|
},
|
||||||
|
getPathToItem({
|
||||||
|
current,
|
||||||
|
targetId,
|
||||||
|
parentIds = [],
|
||||||
|
}: {
|
||||||
|
current: ProjectExtendedItem[]
|
||||||
|
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: ProjectExtendedItem): boolean {
|
||||||
|
return (item.children ?? []).length > 0;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(type: FileType): void
|
||||||
|
{
|
||||||
|
if(!navigation.value)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const news = [...tree.search(navigation.value, 'title', 'Nouveau')].filter((e, i, a) => a.indexOf(e) === i);
|
||||||
|
const title = `Nouveau${news.length > 0 ? ' (' + news.length +')' : ''}`;
|
||||||
|
const item: ProjectExtendedItem = { navigable: true, private: false, parent: '', path: '', title: title, name: parsePath(title), type: type, order: 0, children: type === 'folder' ? [] : undefined, customPath: false };
|
||||||
|
|
||||||
|
if(!selected.value)
|
||||||
|
{
|
||||||
|
navigation.value = [...navigation.value, item];
|
||||||
|
}
|
||||||
|
else if(selected.value?.children)
|
||||||
|
{
|
||||||
|
item.parent = getPath(selected.value);
|
||||||
|
navigation.value = tree.insertChild(navigation.value, item.parent, item);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
navigation.value = tree.insertAfter(navigation.value, getPath(selected.value), item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateTree(instruction: Instruction, itemId: string, targetId: string) : ProjectExtendedItem[] | undefined {
|
||||||
|
if(!navigation.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const item = tree.find(navigation.value, itemId);
|
||||||
|
const target = tree.find(navigation.value, targetId);
|
||||||
|
|
||||||
|
if(!item)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (instruction.type === 'reparent') {
|
||||||
|
const path = tree.getPathToItem({
|
||||||
|
current: navigation.value,
|
||||||
|
targetId: targetId,
|
||||||
|
});
|
||||||
|
if (!path) {
|
||||||
|
console.error(`missing ${path}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredId = path[instruction.desiredLevel];
|
||||||
|
let result = tree.remove(navigation.value, itemId);
|
||||||
|
result = tree.insertAfter(result, desiredId, item);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the rest of the actions require you to drop on something else
|
||||||
|
if (itemId === targetId)
|
||||||
|
return navigation.value;
|
||||||
|
|
||||||
|
if (instruction.type === 'reorder-above') {
|
||||||
|
let result = tree.remove(navigation.value, itemId);
|
||||||
|
result = tree.insertBefore(result, targetId, item);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instruction.type === 'reorder-below') {
|
||||||
|
let result = tree.remove(navigation.value, itemId);
|
||||||
|
result = tree.insertAfter(result, targetId, item);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instruction.type === 'make-child') {
|
||||||
|
if(!target || target.type !== 'folder')
|
||||||
|
return;
|
||||||
|
|
||||||
|
let result = tree.remove(navigation.value, itemId);
|
||||||
|
result = tree.insertChild(result, targetId, item);
|
||||||
|
rebuildPath([item], targetId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return navigation.value;
|
||||||
|
}
|
||||||
|
function transform(items: ProjectItem[] | undefined): ProjectExtendedItem[] | undefined
|
||||||
|
{
|
||||||
|
return items?.map(e => ({...e, customPath: e.name !== parsePath(e.title), children: transform(e.children)}));
|
||||||
|
}
|
||||||
|
function drop(instruction: Instruction, itemId: string, targetId: string)
|
||||||
|
{
|
||||||
|
navigation.value = updateTree(instruction, itemId, targetId) ?? navigation.value ?? [];
|
||||||
|
}
|
||||||
|
function rebuildPath(tree: ProjectExtendedItem[] | null | undefined, parentPath: string)
|
||||||
|
{
|
||||||
|
if(!tree)
|
||||||
|
return;
|
||||||
|
|
||||||
|
tree.forEach(e => {
|
||||||
|
e.parent = parentPath;
|
||||||
|
rebuildPath(e.children, getPath(e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function save(redirect: boolean): Promise<void>
|
||||||
|
{
|
||||||
|
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' });
|
||||||
|
} catch(e: any) {
|
||||||
|
toaster.add({
|
||||||
|
type: 'error', content: e.message, timer: true, duration: 10000
|
||||||
|
})
|
||||||
|
saveStatus.value = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getPath(item: ProjectExtendedItem): string
|
||||||
|
{
|
||||||
|
return [item.parent, parsePath(item.customPath ? item.name : item.title)].filter(e => !!e).join('/');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
Index
|
|
||||||
</template>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
49
pages/roadmap.vue
Normal file
49
pages/roadmap.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Roadmap</Title>
|
||||||
|
</Head>
|
||||||
|
<div class="flex flex-col justify-start p-6">
|
||||||
|
<ProseH2>Roadmap</ProseH2>
|
||||||
|
<div class="grid grid-cols-4 gap-x-2 gap-y-4">
|
||||||
|
<div v-if="loggedIn && user && hasPermissions(user.permissions, ['admin'])" class="flex flex-col gap-2 justify-start">
|
||||||
|
<ProseH3>Administration</ProseH3>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Dashboard de statistiques</span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Editeur de permissions</span><ProseTag>prioritaire</ProseTag></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Synchro project <-> GIT</span><ProseTag>prioritaire</ProseTag></Label>
|
||||||
|
</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" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Validation du compte par mail<ProseTag>prioritaire</ProseTag></span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Modification de profil</span></Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Image de profil</span></Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||||
|
import { hasPermissions } from '~/shared/auth.util';
|
||||||
|
|
||||||
|
const { loggedIn, user } = useUserSession();
|
||||||
|
</script>
|
||||||
@@ -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">
|
||||||
|
|||||||
18
pages/user/mailvalidated.vue
Normal file
18
pages/user/mailvalidated.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<Head>
|
||||||
|
<Title>d[any] - Validation de votre adresse mail</Title>
|
||||||
|
</Head>
|
||||||
|
<div class="flex flex-col justify-center items-center">
|
||||||
|
<ProseH2>Votre compte a été validé ! 🎉</ProseH2>
|
||||||
|
<div class="flex flex-row gap-8">
|
||||||
|
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'user-login', replace: true }">Se connecter</NuxtLink></Button>
|
||||||
|
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'index', replace: true }">Retourner à l'accueil</NuxtLink></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'login',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,14 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { hasPermissions } from "#shared/auth.util";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
guestsGoesTo: '/user/login',
|
guestsGoesTo: '/user/login',
|
||||||
})
|
})
|
||||||
let { user, clear } = useUserSession();
|
const { user, clear } = useUserSession();
|
||||||
|
const toaster = useToast();
|
||||||
|
const loading = ref<boolean>(false);
|
||||||
|
|
||||||
|
async function revalidateUser()
|
||||||
|
{
|
||||||
|
loading.value = true;
|
||||||
|
await $fetch(`/api/users/${user.value?.id}/revalidate`, {
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
loading.value = false;
|
||||||
|
toaster.add({ closeable: false, duration: 10000, timer: true, content: 'Un mail vous a été envoyé.', type: 'info' });
|
||||||
|
}
|
||||||
async function deleteUser()
|
async function deleteUser()
|
||||||
{
|
{
|
||||||
|
loading.value = true;
|
||||||
await $fetch(`/api/users/${user.value?.id}`, {
|
await $fetch(`/api/users/${user.value?.id}`, {
|
||||||
method: 'delete'
|
method: 'delete'
|
||||||
});
|
});
|
||||||
|
loading.value = false;
|
||||||
clear();
|
clear();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -16,7 +31,7 @@ async function deleteUser()
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
<Head>
|
<Head>
|
||||||
<Title>Mon profil</Title>
|
<Title>d[any] - Mon profil</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<div class="grid lg:grid-cols-4 grid-col-2 w-full items-start py-8 gap-6 content-start" v-if="user">
|
<div class="grid lg:grid-cols-4 grid-col-2 w-full items-start py-8 gap-6 content-start" v-if="user">
|
||||||
<div class="flex flex-col gap-4 col-span-4 lg:col-span-3 border border-light-35 dark:border-dark-35 p-4">
|
<div class="flex flex-col gap-4 col-span-4 lg:col-span-3 border border-light-35 dark:border-dark-35 p-4">
|
||||||
@@ -34,14 +49,14 @@ async function deleteUser()
|
|||||||
<template v-slot:content><span>Tant que votre adresse mail n'as pas été validée, vous n'avez que
|
<template v-slot:content><span>Tant que votre adresse mail n'as pas été validée, vous n'avez que
|
||||||
des droits de lecture.</span></template>
|
des droits de lecture.</span></template>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
<Tooltip message="En cours de développement"><Button class="ms-4" disabled>Renvoyez un mail</Button></Tooltip>
|
<Button class="ms-4" @click="revalidateUser" :loading="loading">Renvoyez un mail</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col self-center flex-1 gap-4">
|
<div class="flex flex-col self-center flex-1 gap-4">
|
||||||
<Button @click="async () => await clear()">Se deconnecter</Button>
|
<Button @click="clear">Se deconnecter</Button>
|
||||||
<Button disabled><Tooltip message="En cours de développement">Modifier mon profil</Tooltip></Button>
|
<Button disabled><Tooltip message="En cours de développement">Modifier mon profil</Tooltip></Button>
|
||||||
<AlertDialogRoot>
|
<AlertDialogRoot>
|
||||||
<AlertDialogTrigger asChild><Button
|
<AlertDialogTrigger asChild><Button :loading="loading"
|
||||||
class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer
|
class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer
|
||||||
mon compte</Button></AlertDialogTrigger>
|
mon compte</Button></AlertDialogTrigger>
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
@@ -64,19 +79,5 @@ async function deleteUser()
|
|||||||
</AlertDialogRoot>
|
</AlertDialogRoot>
|
||||||
<NuxtLink v-if="hasPermissions(user.permissions, ['admin'])" :href="{ name: 'admin' }" class="flex" no-prefetch><Button class="flex-1">Administration</Button></NuxtLink>
|
<NuxtLink v-if="hasPermissions(user.permissions, ['admin'])" :href="{ name: 'admin' }" class="flex" no-prefetch><Button class="flex-1">Administration</Button></NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex" v-if="user.permissions">
|
|
||||||
<ProseTable class="!m-0">
|
|
||||||
<ProseThead>
|
|
||||||
<ProseTr>
|
|
||||||
<ProseTh>Permission</ProseTh>
|
|
||||||
</ProseTr>
|
|
||||||
</ProseThead>
|
|
||||||
<ProseTbody>
|
|
||||||
<ProseTr v-for="permission in user.permissions">
|
|
||||||
<ProseTd>{{ permission }}</ProseTd>
|
|
||||||
</ProseTr>
|
|
||||||
</ProseTbody>
|
|
||||||
</ProseTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Head>
|
<Head>
|
||||||
<Title>Inscription</Title>
|
<Title>d[any] - Inscription</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<div class="flex flex-1 flex-col justify-center items-center">
|
<div class="flex flex-1 flex-col justify-center items-center">
|
||||||
<div class="flex gap-8 items-center">
|
<div class="flex gap-8 items-center">
|
||||||
@@ -11,19 +11,13 @@
|
|||||||
<TextInput type="text" label="Nom d'utilisateur" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
|
<TextInput type="text" label="Nom d'utilisateur" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
|
||||||
<TextInput type="email" label="Email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/>
|
<TextInput type="email" label="Email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/>
|
||||||
<TextInput type="password" label="Mot de passe" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/>
|
<TextInput type="password" label="Mot de passe" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/>
|
||||||
<div class="flex flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||||
<span class="">Votre mot de passe doit respecter les critères de sécurité suivants
|
<span class="col-span-2">Prérequis de sécurité</span>
|
||||||
:</span>
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
|
||||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedLength}">Entre 8 et 128
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLower}"><Icon v-show="!checkedLower" icon="radix-icons:cross-2" />Une minuscule</span>
|
||||||
caractères</span>
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedUpper}"><Icon v-show="!checkedUpper" icon="radix-icons:cross-2" />Une majuscule</span>
|
||||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedLowerUpper}">Au moins
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedDigit}"><Icon v-show="!checkedDigit" icon="radix-icons:cross-2" />Un chiffre</span>
|
||||||
une minuscule et une majuscule</span>
|
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
|
||||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedDigit}">Au moins un
|
|
||||||
chiffre</span>
|
|
||||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}">Au moins un
|
|
||||||
caractère spécial parmi la liste suivante:
|
|
||||||
<pre class="text-wrap">! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ ] ^ _ ` { | } ~</pre>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
|
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
|
||||||
<Button class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
|
<Button 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>
|
||||||
@@ -52,7 +46,8 @@ const { add: addToast, clear: clearToasts } = useToast();
|
|||||||
const confirmPassword = ref("");
|
const confirmPassword = ref("");
|
||||||
|
|
||||||
const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128);
|
const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128);
|
||||||
const checkedLowerUpper = computed(() => state.password.toLowerCase() !== state.password && state.password.toUpperCase() !== state.password);
|
const checkedLower = computed(() => state.password.toUpperCase() !== state.password);
|
||||||
|
const checkedUpper = computed(() => state.password.toLowerCase() !== state.password);
|
||||||
const checkedDigit = computed(() => /[0-9]/.test(state.password));
|
const checkedDigit = computed(() => /[0-9]/.test(state.password));
|
||||||
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));
|
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));
|
||||||
|
|
||||||
|
|||||||
BIN
public/logo.light.png
Normal file
BIN
public/logo.light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -1 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://obsidian.peaceultime.com/sitemap.xml
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const fileType = z.enum(['folder', 'file', 'markdown', 'canvas']);
|
||||||
export const schema = z.object({
|
export const schema = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
owner: z.number(),
|
owner: z.number().finite(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
type: z.enum(['folder', 'file', 'markdown', 'canvas']),
|
type: fileType,
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
navigable: z.boolean(),
|
navigable: z.boolean(),
|
||||||
private: z.boolean(),
|
private: z.boolean(),
|
||||||
|
order: z.number().finite(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type FileType = z.infer<typeof fileType>;
|
||||||
export type File = z.infer<typeof schema>;
|
export type File = z.infer<typeof schema>;
|
||||||
16
schemas/navigation.ts
Normal file
16
schemas/navigation.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { fileType } from "./file";
|
||||||
|
|
||||||
|
export const single = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
owner: z.number().finite(),
|
||||||
|
title: z.string(),
|
||||||
|
type: fileType,
|
||||||
|
navigable: z.boolean(),
|
||||||
|
private: z.boolean(),
|
||||||
|
order: z.number().finite(),
|
||||||
|
});
|
||||||
|
export const table = z.array(single);
|
||||||
|
|
||||||
|
export type Navigation = z.infer<typeof table>;
|
||||||
|
export type NavigationItem = z.infer<typeof single>;
|
||||||
24
schemas/project.ts
Normal file
24
schemas/project.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { fileType } from "./file";
|
||||||
|
|
||||||
|
const baseItem = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
parent: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
type: fileType,
|
||||||
|
navigable: z.boolean(),
|
||||||
|
private: z.boolean(),
|
||||||
|
order: z.number().finite(),
|
||||||
|
});
|
||||||
|
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[]
|
||||||
|
};
|
||||||
@@ -29,7 +29,7 @@ function securePassword(password: string, ctx: z.RefinementCtx): void {
|
|||||||
{
|
{
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "Votre mot de passe doit contenir au moins un symbole",
|
message: "Votre mot de passe doit contenir au moins un caractère spécial",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
server/api/__sitemap__/urls.ts
Normal file
14
server/api/__sitemap__/urls.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { SitemapUrlInput } from '#sitemap/types'
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { explorerContentTable } from '~/db/schema';
|
||||||
|
import useDatabase from '~/composables/useDatabase';
|
||||||
|
|
||||||
|
export default defineSitemapEventHandler(() => {
|
||||||
|
const db = useDatabase();
|
||||||
|
const pages = db.select({ path: explorerContentTable.path, lastMod: explorerContentTable.timestamp }).from(explorerContentTable).where(and(eq(explorerContentTable.private, false), eq(explorerContentTable.navigable, true))).all();
|
||||||
|
|
||||||
|
return pages.map(e => ({
|
||||||
|
loc: `/explore/${e.path}`,
|
||||||
|
lastmod: e.lastMod,
|
||||||
|
})) satisfies SitemapUrlInput[];
|
||||||
|
})
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import { hasPermissions } from "#shared/auth.util";
|
||||||
|
|
||||||
|
declare module 'nitropack'
|
||||||
|
{
|
||||||
|
interface TaskPayload
|
||||||
|
{
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (e) => {
|
export default defineEventHandler(async (e) => {
|
||||||
const session = await getUserSession(e);
|
const session = await getUserSession(e);
|
||||||
|
|
||||||
@@ -7,19 +17,31 @@ export default defineEventHandler(async (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = getRouterParam(e, 'id');
|
const id = getRouterParam(e, 'id');
|
||||||
|
const payload: Record<string, any> = await readBody(e);
|
||||||
|
|
||||||
if(!id)
|
if(!id)
|
||||||
{
|
{
|
||||||
setResponseStatus(e, 400);
|
setResponseStatus(e, 400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
payload.type = id;
|
||||||
|
payload.data = JSON.parse(payload.data);
|
||||||
|
|
||||||
const result = await runTask(id);
|
const result = await runTask(id, {
|
||||||
|
payload: payload
|
||||||
|
});
|
||||||
|
|
||||||
if(!result.result)
|
if(!result.result)
|
||||||
{
|
{
|
||||||
setResponseStatus(e, 500);
|
setResponseStatus(e, 500);
|
||||||
throw result.error ?? new Error('Erreur inconnue');
|
|
||||||
|
if(result.error && (result.error as Error).message)
|
||||||
|
throw result.error;
|
||||||
|
else if(result.error)
|
||||||
|
throw new Error(result.error);
|
||||||
|
else
|
||||||
|
throw new Error('Erreur inconnue');
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
});
|
});
|
||||||
50
server/api/admin/pages.get.ts
Normal file
50
server/api/admin/pages.get.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { ne, sql } from 'drizzle-orm';
|
||||||
|
import useDatabase from '~/composables/useDatabase';
|
||||||
|
import { explorerContentTable } from '~/db/schema';
|
||||||
|
import { hasPermissions } from '~/shared/auth.util';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const session = await getUserSession(e);
|
||||||
|
|
||||||
|
if(!session || !session.user || !hasPermissions(session.user.permissions, ['admin']))
|
||||||
|
{
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDatabase();
|
||||||
|
const content = db.select({
|
||||||
|
path: explorerContentTable.path,
|
||||||
|
owner: explorerContentTable.owner,
|
||||||
|
title: explorerContentTable.title,
|
||||||
|
type: explorerContentTable.type,
|
||||||
|
size: sql<number>`CASE WHEN ${explorerContentTable.content} IS NULL THEN 0 ELSE length(${explorerContentTable.content}) END`.as('size'),
|
||||||
|
navigable: explorerContentTable.navigable,
|
||||||
|
private: explorerContentTable.private,
|
||||||
|
order: explorerContentTable.order,
|
||||||
|
visit: explorerContentTable.visit,
|
||||||
|
timestamp: explorerContentTable.timestamp,
|
||||||
|
}).from(explorerContentTable).all();
|
||||||
|
|
||||||
|
content.sort((a, b) => {
|
||||||
|
return a.path.split('/').length - b.path.split('/').length;
|
||||||
|
});
|
||||||
|
|
||||||
|
for(let i = 0; i < content.length; i++)
|
||||||
|
{
|
||||||
|
const path = content[i].path.substring(0, content[i].path.lastIndexOf('/'));
|
||||||
|
if(path !== '')
|
||||||
|
{
|
||||||
|
const parent = content.find(e => e.path === path);
|
||||||
|
|
||||||
|
if(parent)
|
||||||
|
{
|
||||||
|
content[i].private = content[i].private || parent.private;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.filter(e => e.type !== 'folder');
|
||||||
|
})
|
||||||
55
server/api/admin/user/[id]/permissions.post.ts
Normal file
55
server/api/admin/user/[id]/permissions.post.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { hasPermissions } from "~/shared/auth.util";
|
||||||
|
import useDatabase from '~/composables/useDatabase';
|
||||||
|
import { and, eq, notInArray } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { userPermissionsTable } from "~/db/schema";
|
||||||
|
|
||||||
|
const schema = z.array(z.string());
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const session = await getUserSession(e);
|
||||||
|
|
||||||
|
if(!session || !session.user || !hasPermissions(session.user.permissions, ['admin']))
|
||||||
|
{
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const param = getRouterParam(e, 'id');
|
||||||
|
|
||||||
|
if(!param)
|
||||||
|
{
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Forbidden',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readValidatedBody(e, schema.safeParse);
|
||||||
|
|
||||||
|
if(!body.success)
|
||||||
|
{
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Forbidden',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = parseInt(param, 10);
|
||||||
|
|
||||||
|
const db = useDatabase();
|
||||||
|
const permissions = body.data.map(e => ({ id: id, permission: e }));
|
||||||
|
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.delete(userPermissionsTable).where(eq(userPermissionsTable.id, id)).run();
|
||||||
|
tx.insert(userPermissionsTable).values(permissions).run();
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
34
server/api/admin/users.get.ts
Normal file
34
server/api/admin/users.get.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import useDatabase from '~/composables/useDatabase';
|
||||||
|
import { userSessionsTable } from '~/db/schema';
|
||||||
|
import { hasPermissions } from '~/shared/auth.util';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const session = await getUserSession(e);
|
||||||
|
|
||||||
|
if(!session || !session.user || !hasPermissions(session.user.permissions, ['admin']))
|
||||||
|
{
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDatabase();
|
||||||
|
return db.query.usersTable.findMany({
|
||||||
|
columns: {
|
||||||
|
email: false,
|
||||||
|
hash: false,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
data: true,
|
||||||
|
permission: true,
|
||||||
|
session: {
|
||||||
|
columns: {
|
||||||
|
timestamp: false,
|
||||||
|
user_id: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).sync();
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import { schema } from '~/schemas/login';
|
|||||||
import type { UserSession, UserSessionRequired } from '~/types/auth';
|
import type { UserSession, UserSessionRequired } from '~/types/auth';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
import { checkSession, logSession } from '~/server/utils/user';
|
import { checkSession, logSession } from '~/server/utils/user';
|
||||||
import { usersTable } from '~/db/schema';
|
import { usersDataTable, usersTable } from '~/db/schema';
|
||||||
import { eq, or, sql } from 'drizzle-orm';
|
import { eq, or, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
interface SuccessHandler
|
interface SuccessHandler
|
||||||
@@ -93,6 +93,8 @@ export default defineEventHandler(async (e): Promise<Return> => {
|
|||||||
}
|
}
|
||||||
}) as UserSessionRequired);
|
}) as UserSessionRequired);
|
||||||
|
|
||||||
|
db.update(usersDataTable).set({ logCount: user.data.logCount + 1 }).where(eq(usersDataTable.id, user.id)).run();
|
||||||
|
|
||||||
setResponseStatus(e, 201);
|
setResponseStatus(e, 201);
|
||||||
return { success: true, session: data };
|
return { success: true, session: data };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,19 @@ export default defineEventHandler(async (e): Promise<Return> => {
|
|||||||
|
|
||||||
logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [] } }) as UserSessionRequired);
|
logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [] } }) as UserSessionRequired);
|
||||||
|
|
||||||
|
await runTask('mail', {
|
||||||
|
payload: {
|
||||||
|
type: 'mail',
|
||||||
|
to: [body.data.email],
|
||||||
|
template: 'registration',
|
||||||
|
data: {
|
||||||
|
username: body.data.username,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
id: id.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setResponseStatus(e, 201);
|
setResponseStatus(e, 201);
|
||||||
return { success: true, session };
|
return { success: true, session };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { and, eq, like, sql } from 'drizzle-orm';
|
|
||||||
import useDatabase from '~/composables/useDatabase';
|
|
||||||
import { explorerContentTable } from '~/db/schema';
|
|
||||||
|
|
||||||
export default defineEventHandler(async (e) => {
|
|
||||||
const query = getQuery(e);
|
|
||||||
const where = [];
|
|
||||||
|
|
||||||
if(query && query.path !== undefined)
|
|
||||||
{
|
|
||||||
where.push(eq(explorerContentTable.path, sql.placeholder('path')));
|
|
||||||
}
|
|
||||||
if(query && query.title !== undefined)
|
|
||||||
{
|
|
||||||
where.push(eq(explorerContentTable.title, sql.placeholder('title')));
|
|
||||||
}
|
|
||||||
if(query && query.type !== undefined)
|
|
||||||
{
|
|
||||||
where.push(eq(explorerContentTable.type, sql.placeholder('type')));
|
|
||||||
}
|
|
||||||
if (query && query.search !== undefined)
|
|
||||||
{
|
|
||||||
where.push(like(explorerContentTable.path, sql.placeholder('search')));
|
|
||||||
}
|
|
||||||
|
|
||||||
if(where.length > 0)
|
|
||||||
{
|
|
||||||
const db = useDatabase();
|
|
||||||
|
|
||||||
const content = db.select({
|
|
||||||
'path': explorerContentTable.path,
|
|
||||||
'owner': explorerContentTable.owner,
|
|
||||||
'title': explorerContentTable.title,
|
|
||||||
'type': explorerContentTable.type,
|
|
||||||
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
|
|
||||||
'navigable': explorerContentTable.navigable,
|
|
||||||
'private': explorerContentTable.private,
|
|
||||||
}).from(explorerContentTable).where(and(...where)).prepare().all(query);
|
|
||||||
|
|
||||||
if(content.length > 0)
|
|
||||||
{
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setResponseStatus(e, 404);
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import useDatabase from '~/composables/useDatabase';
|
import useDatabase from '~/composables/useDatabase';
|
||||||
import { explorerContentTable } from '~/db/schema';
|
import { explorerContentTable } from '~/db/schema';
|
||||||
import { schema } from '~/schemas/file';
|
import { schema } from '~/schemas/file';
|
||||||
|
import { parsePath } from '~/shared/general.utils';
|
||||||
|
|
||||||
export default defineEventHandler(async (e) => {
|
export default defineEventHandler(async (e) => {
|
||||||
const body = await readValidatedBody(e, schema.safeParse);
|
const body = await readValidatedBody(e, schema.safeParse);
|
||||||
@@ -10,10 +11,11 @@ export default defineEventHandler(async (e) => {
|
|||||||
throw body.error;
|
throw body.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(body.data.content, 'utf-8');
|
|
||||||
|
|
||||||
const db = useDatabase();
|
const db = useDatabase();
|
||||||
const content = db.insert(explorerContentTable).values({ ...body.data, content: buffer }).onConflictDoUpdate({ target: explorerContentTable.path, set: { ...body.data, content: buffer } });
|
|
||||||
|
const buffer = Buffer.from(convertToStorableLinks(body.data.content, db.select({ path: explorerContentTable.path }).from(explorerContentTable).all().map(e => e.path)), 'utf-8');
|
||||||
|
|
||||||
|
const content = db.insert(explorerContentTable).values({ ...body.data, content: buffer }).onConflictDoUpdate({ target: explorerContentTable.path, set: { ...body.data, content: buffer, timestamp: new Date() } });
|
||||||
|
|
||||||
if(content !== undefined)
|
if(content !== undefined)
|
||||||
{
|
{
|
||||||
@@ -22,4 +24,14 @@ export default defineEventHandler(async (e) => {
|
|||||||
|
|
||||||
setResponseStatus(e, 404);
|
setResponseStatus(e, 404);
|
||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function convertToStorableLinks(content: string, path: string[]): string
|
||||||
|
{
|
||||||
|
return content.replaceAll(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (e: string, a1?: string, a2?: string , a3?: string) => {
|
||||||
|
const parsed = parsePath(a1 ?? '%%%%----%%%%----%%%%');
|
||||||
|
const replacer = path.find(e => e.endsWith(parsed));
|
||||||
|
const value = `[[${replacer ?? ''}${a2 ?? ''}${(!a3 && replacer !== parsed ? '|' + a1 : a3) ?? ''}]]`;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
62
server/api/file/content/[path].get.ts
Normal file
62
server/api/file/content/[path].get.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import useDatabase from '~/composables/useDatabase';
|
||||||
|
import { explorerContentTable } from '~/db/schema';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
|
||||||
|
const query = getQuery(e);
|
||||||
|
|
||||||
|
if(!path)
|
||||||
|
{
|
||||||
|
setResponseStatus(e, 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDatabase();
|
||||||
|
const content = db.select({
|
||||||
|
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
|
||||||
|
'private': explorerContentTable.private,
|
||||||
|
'owner': explorerContentTable.owner,
|
||||||
|
'visit': explorerContentTable.visit,
|
||||||
|
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
|
||||||
|
|
||||||
|
if(content !== undefined)
|
||||||
|
{
|
||||||
|
const session = await getUserSession(e);
|
||||||
|
|
||||||
|
if(content.private && (!session || !session.user))
|
||||||
|
{
|
||||||
|
setResponseStatus(e, 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(session && session.user && content.private && session.user.id !== content.owner)
|
||||||
|
{
|
||||||
|
setResponseStatus(e, 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(query.type === 'view')
|
||||||
|
{
|
||||||
|
db.update(explorerContentTable).set({ visit: content.visit + 1 }).where(eq(explorerContentTable.path, path)).run();
|
||||||
|
}
|
||||||
|
if(query.type === 'editing')
|
||||||
|
{
|
||||||
|
content.content = convertFromStorableLinks(content.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: content.content };
|
||||||
|
}
|
||||||
|
|
||||||
|
setResponseStatus(e, 404);
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
export function convertFromStorableLinks(content: string): string
|
||||||
|
{
|
||||||
|
/*return content.replaceAll(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (e: string, a1?: string, a2?: string , a3?: string) => {
|
||||||
|
const parsed = parsePath(a1 ?? '%%%%----%%%%----%%%%');
|
||||||
|
const replacer = path.find(e => e.endsWith(parsed)) ?? parsed;
|
||||||
|
const value = `[[${replacer}${a2 ?? ''}${(!a3 && replacer !== parsed ? '|' + a1 : a3) ?? ''}]]`;
|
||||||
|
return value;
|
||||||
|
});*/
|
||||||
|
return content;
|
||||||
|
}
|
||||||
@@ -18,13 +18,27 @@ export default defineEventHandler(async (e) => {
|
|||||||
'owner': explorerContentTable.owner,
|
'owner': explorerContentTable.owner,
|
||||||
'title': explorerContentTable.title,
|
'title': explorerContentTable.title,
|
||||||
'type': explorerContentTable.type,
|
'type': explorerContentTable.type,
|
||||||
'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,
|
||||||
|
'visit': explorerContentTable.visit,
|
||||||
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
|
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
|
||||||
|
|
||||||
if(content !== undefined)
|
if(content !== undefined)
|
||||||
{
|
{
|
||||||
|
const session = await getUserSession(e);
|
||||||
|
|
||||||
|
if(content.private && (!session || !session.user))
|
||||||
|
{
|
||||||
|
setResponseStatus(e, 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(session && session.user && content.private && session.user.id !== content.owner)
|
||||||
|
{
|
||||||
|
setResponseStatus(e, 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,21 +1,29 @@
|
|||||||
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).all();
|
||||||
|
|
||||||
|
content.sort((a, b) => {
|
||||||
|
return a.path.split('/').length - b.path.split('/').length;
|
||||||
|
});
|
||||||
|
|
||||||
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 +51,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 +67,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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
80
server/api/project.get.ts
Normal file
80
server/api/project.get.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
content.sort((a, b) => {
|
||||||
|
return a.path.split('/').length - b.path.split('/').length;
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
77
server/api/project.post.ts
Normal file
77
server/api/project.post.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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";
|
||||||
|
import { eq, getTableColumns } from "drizzle-orm";
|
||||||
|
|
||||||
|
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();
|
||||||
|
const { content, ...cols } = getTableColumns(explorerContentTable);
|
||||||
|
const full = db.select(cols).from(explorerContentTable).all();
|
||||||
|
|
||||||
|
for(let i = full.length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if(items.find(e => (e.path === '' ? [e.parent, parsePath(e.name === '' ? e.title : e.name)].filter(e => !!e).join('/') : e.path) === full[i].path))
|
||||||
|
full.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: null,
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
set: {
|
||||||
|
path: [item.parent, parsePath(item.name === '' ? item.title : item.name)].filter(e => !!e).join('/'),
|
||||||
|
title: item.title,
|
||||||
|
type: item.type,
|
||||||
|
navigable: item.navigable,
|
||||||
|
private: item.private,
|
||||||
|
order: item.order,
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
target: explorerContentTable.path,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
for(let i = 0; i < full.length; i++)
|
||||||
|
{
|
||||||
|
tx.delete(explorerContentTable).where(eq(explorerContentTable.path, full[i].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 ?? [])]);
|
||||||
|
}
|
||||||
64
server/api/users/[id]/revalidate.get.ts
Normal file
64
server/api/users/[id]/revalidate.get.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import useDatabase from "~/composables/useDatabase";
|
||||||
|
import { usersTable } from "~/db/schema";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const session = await getUserSession(e);
|
||||||
|
|
||||||
|
if(!session || !session.user || !session.user.id)
|
||||||
|
{
|
||||||
|
return createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getRouterParam(e, 'id');
|
||||||
|
|
||||||
|
if(!id)
|
||||||
|
{
|
||||||
|
return createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Forbidden',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(session.user.id.toString() !== id)
|
||||||
|
{
|
||||||
|
return createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDatabase();
|
||||||
|
const data = db.select({ state: usersTable.state }).from(usersTable).where(eq(usersTable.id, session.user.id)).get();
|
||||||
|
|
||||||
|
if(!data)
|
||||||
|
{
|
||||||
|
return createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(data.state === 1)
|
||||||
|
{
|
||||||
|
setResponseStatus(e, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runTask('mail', {
|
||||||
|
payload: {
|
||||||
|
type: 'mail',
|
||||||
|
to: [session.user.email],
|
||||||
|
template: 'registration', //@TODO
|
||||||
|
data: {
|
||||||
|
username: session.user.username,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
id: session.user.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setResponseStatus(e, 200);
|
||||||
|
return;
|
||||||
|
})
|
||||||
16
server/components/mail/base.vue
Normal file
16
server/components/mail/base.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div style='margin-left: auto; margin-right: auto; width: 75%; font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; line-height: 1.5rem; color: #171717;'>
|
||||||
|
<div style="margin-left: auto; margin-right: auto; text-align: center;">
|
||||||
|
<a href="https://obsidian.peaceultime.com">
|
||||||
|
<img src="https://obsidian.peaceultime.com/logo.light.png" alt="Logo" title="d[any] logo" width="64" height="64" style="display: block; height: 4rem; width: 4rem; margin-left: auto; margin-right: auto;" />
|
||||||
|
<span style="margin-inline-end: 1rem; font-size: 1.5rem; line-height: 2rem; font-weight: 700; font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;">d[any]</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 1rem;">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background-color: #171717;">
|
||||||
|
<p style="padding-top: 1rem; padding-bottom: 1rem; text-align: center; font-size: 0.75rem; line-height: 1rem; color: #fff;">Copyright Peaceultime - d[any] - 2024</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
26
server/components/mail/registration.vue
Normal file
26
server/components/mail/registration.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div style="max-width: 800px; margin-left: auto; margin-right: auto;">
|
||||||
|
<p style="font-variant: small-caps; margin-bottom: 1rem; font-size: 1.25rem; line-height: 1.75rem;">Bienvenue sur d[any], <span>{{ username }}</span>.</p>
|
||||||
|
<p>Nous vous invitons à valider votre compte afin de profiter de toutes les fonctionnalités de d[any].</p>
|
||||||
|
<div style="padding-top: 1rem; padding-bottom: 1rem; text-align: center;">
|
||||||
|
<a :href="`https://obsidian.peaceultime.com/user/mailvalidation?u=${id}&t=${timestamp}&h=${hash}`" target="_blank"><span style="display: inline-block; border-width: 1px; border-color: #525252; background-color: #e5e5e5; padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem; font-weight: 200; color: #171717; text-decoration: none;">Je valide mon compte</span></a>
|
||||||
|
<span style="display: block; padding-top: 0.5rem; font-size: 0.75rem; line-height: 1rem;">Ce lien est valable 1 heure.</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Vous pouvez egalement copier le lien suivant pour valider votre compte: </span>
|
||||||
|
<pre style="display: inline-block; border-bottom-width: 1px; font-size: 0.75rem; line-height: 1rem; color: #171717; font-weight: 400; text-decoration: none;">{{ `https://obsidian.peaceultime.com/user/mailvalidation?u=${id}&t=${timestamp}&h=${hash}` }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Bun from 'bun';
|
||||||
|
|
||||||
|
const { id, username, timestamp } = defineProps<{
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
timestamp: number
|
||||||
|
}>();
|
||||||
|
const hash = computed(() => Bun.hash(id.toString(), timestamp));
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import useDatabase from "~/composables/useDatabase";
|
import useDatabase from "~/composables/useDatabase";
|
||||||
import { userSessionsTable } from "~/db/schema";
|
import { usersDataTable, userSessionsTable } from "~/db/schema";
|
||||||
import { eq, and, sql, lte } from "drizzle-orm";
|
import { eq, and, sql, lte } from "drizzle-orm";
|
||||||
import { refreshSessionFromDB } from "../utils/user";
|
import { refreshSessionFromDB } from "../utils/user";
|
||||||
|
|
||||||
@@ -19,9 +19,14 @@ export default defineNitroPlugin(() => {
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await db.update(userSessionsTable).set({
|
db.update(userSessionsTable).set({
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
}).where(and(eq(userSessionsTable.id, sql.placeholder('id')), eq(userSessionsTable.user_id, sql.placeholder('user_id')))).prepare().run({ id: session.id, user_id: session.user.id });
|
}).where(and(eq(userSessionsTable.id, sql.placeholder('id')), eq(userSessionsTable.user_id, sql.placeholder('user_id')))).prepare().run({ id: session.id, user_id: session.user.id });
|
||||||
|
|
||||||
|
db.update(usersDataTable).set({
|
||||||
|
lastTimestamp: new Date(),
|
||||||
|
}).where(eq(usersDataTable.id, sql.placeholder('user_id'))).prepare().run({ id: session.id, user_id: session.user.id });
|
||||||
|
|
||||||
await refreshSessionFromDB(event, session.id);
|
await refreshSessionFromDB(event, session.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
54
server/routes/user/mailvalidation.get.ts
Normal file
54
server/routes/user/mailvalidation.get.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import useDatabase from "~/composables/useDatabase";
|
||||||
|
import { usersTable } from "~/db/schema";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
h: z.coerce.string(),
|
||||||
|
u: z.coerce.number(),
|
||||||
|
t: z.coerce.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineEventHandler(async (e) => {
|
||||||
|
const query = await getValidatedQuery(e, schema.safeParse);
|
||||||
|
|
||||||
|
if(!query.success)
|
||||||
|
throw query.error;
|
||||||
|
|
||||||
|
if(Bun.hash(query.data.u.toString(), query.data.t).toString() !== query.data.h)
|
||||||
|
{
|
||||||
|
return createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Lien incorrect',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if(Date.now() > query.data.t + (60 * 60 * 1000))
|
||||||
|
{
|
||||||
|
return createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Le lien a expiré',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDatabase();
|
||||||
|
const result = db.select({ state: usersTable.state }).from(usersTable).where(eq(usersTable.id, query.data.u)).get();
|
||||||
|
|
||||||
|
if(result === undefined)
|
||||||
|
{
|
||||||
|
return createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Aucune donnée utilisateur trouvée',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if(result?.state === 1)
|
||||||
|
{
|
||||||
|
return createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Votre compte a déjà été validé',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(usersTable).set({ state: 1 }).where(eq(usersTable.id, query.data.u)).run();
|
||||||
|
|
||||||
|
sendRedirect(e, '/user/mailvalidated');
|
||||||
|
})
|
||||||
110
server/tasks/mail.ts
Normal file
110
server/tasks/mail.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
import base from '../components/mail/base.vue';
|
||||||
|
import registration from '../components/mail/registration.vue';
|
||||||
|
//import revalidation from '../components/mail/revalidation.vue';
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const [domain, selector, dkim] = config.mail.dkim.split(":");
|
||||||
|
|
||||||
|
export const templates: Record<string, { component: any, subject: string }> = {
|
||||||
|
"registration": { component: registration, subject: 'Bienvenue sur d[any] 😎' },
|
||||||
|
// "revalidate-mail": { component: revalidation, subject: 'd[any]: Valider votre email' },
|
||||||
|
};
|
||||||
|
|
||||||
|
import 'nitropack/types';
|
||||||
|
import type Mail from 'nodemailer/lib/mailer';
|
||||||
|
declare module 'nitropack/types'
|
||||||
|
{
|
||||||
|
interface TaskPayload
|
||||||
|
{
|
||||||
|
type: 'mail'
|
||||||
|
to: string[]
|
||||||
|
template: string
|
||||||
|
data: Record<string, any>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = nodemailer.createTransport({
|
||||||
|
//@ts-ignore
|
||||||
|
pool: true,
|
||||||
|
host: config.mail.host,
|
||||||
|
port: config.mail.port,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: config.mail.user,
|
||||||
|
pass: config.mail.passwd,
|
||||||
|
},
|
||||||
|
requireTLS: true,
|
||||||
|
dkim: {
|
||||||
|
domainName: domain,
|
||||||
|
keySelector: selector,
|
||||||
|
privateKey: dkim,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineTask({
|
||||||
|
meta: {
|
||||||
|
name: 'mail',
|
||||||
|
description: 'Send email',
|
||||||
|
},
|
||||||
|
async run(e) {
|
||||||
|
try {
|
||||||
|
if(e.payload.type !== 'mail')
|
||||||
|
{
|
||||||
|
throw new Error(`Données inconnues`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = e.payload;
|
||||||
|
const template = templates[payload.template];
|
||||||
|
|
||||||
|
if(!template)
|
||||||
|
{
|
||||||
|
throw new Error(`Modèle de mail ${payload.template} inconnu`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.time('Generating HTML');
|
||||||
|
const mail: Mail.Options = {
|
||||||
|
from: 'd[any] - Ne pas répondre <no-reply@peaceultime.com>',
|
||||||
|
to: payload.to,
|
||||||
|
html: await render(template.component, payload.data),
|
||||||
|
subject: template.subject,
|
||||||
|
textEncoding: 'quoted-printable',
|
||||||
|
};
|
||||||
|
console.timeEnd('Generating HTML');
|
||||||
|
|
||||||
|
if(mail.html === '')
|
||||||
|
return { result: false, error: new Error("Invalid content") };
|
||||||
|
|
||||||
|
console.time('Sending Mail');
|
||||||
|
const status = await transport.sendMail(mail);
|
||||||
|
console.timeEnd('Sending Mail');
|
||||||
|
|
||||||
|
if(status.rejected.length > 0)
|
||||||
|
{
|
||||||
|
return { result: false, error: status.response, details: status.rejectedErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result: true };
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
return { result: false, error: e };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function render(component: any, data: Record<string, any>): Promise<string>
|
||||||
|
{
|
||||||
|
const app = createSSRApp({
|
||||||
|
render(){
|
||||||
|
return h(base, null, { default: () => h(component, data, { default: () => null }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await renderToString(app);
|
||||||
|
|
||||||
|
return (`<html><body><div>${html}</div></body></html>`);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import useDatabase from "~/composables/useDatabase";
|
import useDatabase from "~/composables/useDatabase";
|
||||||
import { extname, basename } from 'node:path';
|
import { extname, basename } from 'node:path';
|
||||||
import type { File, FileType, Tag } from '~/types/api';
|
import type { FileType } from '~/types/api';
|
||||||
import type { CanvasColor, CanvasContent } from "~/types/canvas";
|
import type { CanvasColor, CanvasContent } from "~/types/canvas";
|
||||||
import { explorerContentTable } from "~/db/schema";
|
import { explorerContentTable } from "~/db/schema";
|
||||||
|
import { convertToStorableLinks } from "../api/file.post";
|
||||||
|
|
||||||
const typeMapping: Record<string, FileType> = {
|
const typeMapping: Record<string, FileType> = {
|
||||||
".md": "markdown",
|
".md": "markdown",
|
||||||
@@ -11,8 +12,8 @@ const typeMapping: Record<string, FileType> = {
|
|||||||
|
|
||||||
export default defineTask({
|
export default defineTask({
|
||||||
meta: {
|
meta: {
|
||||||
name: 'sync',
|
name: 'pull',
|
||||||
description: 'Synchronise the project with Obsidian',
|
description: 'Pull the data from Git',
|
||||||
},
|
},
|
||||||
async run(event) {
|
async run(event) {
|
||||||
try {
|
try {
|
||||||
@@ -25,10 +26,9 @@ export default defineTask({
|
|||||||
recursive: true,
|
recursive: true,
|
||||||
per_page: 1000,
|
per_page: 1000,
|
||||||
}
|
}
|
||||||
}) as any;
|
}) as { tree: any[] } & Record<string, any>;
|
||||||
|
|
||||||
|
const files: typeof explorerContentTable.$inferInsert[] = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e, i) => {
|
||||||
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')
|
||||||
{
|
{
|
||||||
const title = basename(e.path);
|
const title = basename(e.path);
|
||||||
@@ -36,13 +36,13 @@ export default defineTask({
|
|||||||
const path = (e.path as string).split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/');
|
const path = (e.path as string).split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/');
|
||||||
return {
|
return {
|
||||||
path: path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
|
path: path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
|
||||||
//order: order && order[1] ? order[1] : 50,
|
order: i,
|
||||||
title: order && order[2] ? order[2] : title,
|
title: order && order[2] ? order[2] : title,
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
content: null,
|
content: null,
|
||||||
owner: '1',
|
owner: '1',
|
||||||
navigable: true,
|
navigable: true,
|
||||||
private: e.path.startsWith('98.Privé')
|
private: e.path.startsWith('98.Privé'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export default defineTask({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
path: (extension === '.md' ? path.replace(extension, '') : path).toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
|
path: (extension === '.md' ? path.replace(extension, '') : path).toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
|
||||||
//order: order && order[1] ? order[1] : 50,
|
order: i,
|
||||||
title: order && order[2] ? order[2] : title,
|
title: order && order[2] ? order[2] : title,
|
||||||
type: (typeMapping[extension] ?? 'file'),
|
type: (typeMapping[extension] ?? 'file'),
|
||||||
content: reshapeContent(content as string, typeMapping[extension] ?? 'File'),
|
content: reshapeContent(content as string, typeMapping[extension] ?? 'File'),
|
||||||
@@ -64,77 +64,24 @@ export default defineTask({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/*let tags: Tag[] = [];
|
const pathList = files.map(e => e.path);
|
||||||
const tagFile = files.find(e => e.path === "tags");
|
files.forEach(e => {
|
||||||
|
if(e.type !== 'folder' && e.content)
|
||||||
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;
|
e.content = Buffer.from(convertToStorableLinks(e.content.toString('utf-8'), files.map(e => e.path)), 'utf-8');
|
||||||
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();
|
|
||||||
|
|
||||||
return { result: true };
|
return { result: true };
|
||||||
}
|
}
|
||||||
catch(e)
|
catch(e)
|
||||||
{
|
{
|
||||||
|
console.error(e);
|
||||||
|
|
||||||
return { result: false, error: e };
|
return { result: false, error: e };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -145,6 +92,9 @@ function reshapeContent(content: string, type: FileType): string | null
|
|||||||
switch(type)
|
switch(type)
|
||||||
{
|
{
|
||||||
case "markdown":
|
case "markdown":
|
||||||
|
return content.replaceAll(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (e: string, a1?: string, a2?: string , a3?: string) => {
|
||||||
|
return `[[${a1?.split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/') ?? ''}${a2 ?? ''}${a3 ?? ''}]]`;
|
||||||
|
});
|
||||||
case "file":
|
case "file":
|
||||||
return content;
|
return content;
|
||||||
case "canvas":
|
case "canvas":
|
||||||
39
server/tasks/push.ts
Normal file
39
server/tasks/push.ts
Normal 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: 'push',
|
||||||
|
description: 'Push the data to 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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import type { FileType } from "~/schemas/file";
|
||||||
|
|
||||||
export function unifySlug(slug: string | string[]): string
|
export function unifySlug(slug: string | string[]): string
|
||||||
{
|
{
|
||||||
return (Array.isArray(slug) ? slug.join('/') : slug);
|
return (Array.isArray(slug) ? slug.join('/') : slug);
|
||||||
}
|
}
|
||||||
|
export function parsePath(path: string): string
|
||||||
|
{
|
||||||
|
return path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replaceAll(/[\u0300-\u036f]/g, "").replaceAll('(', '').replaceAll(')', '');
|
||||||
|
}
|
||||||
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();
|
||||||
@@ -20,9 +26,9 @@ export function format(date: Date, template: string): string
|
|||||||
"yyyy": (date: Date) => date.getUTCFullYear().toString(),
|
"yyyy": (date: Date) => date.getUTCFullYear().toString(),
|
||||||
"MM": (date: Date) => padRight((date.getUTCMonth() + 1).toString(), '0', 2),
|
"MM": (date: Date) => padRight((date.getUTCMonth() + 1).toString(), '0', 2),
|
||||||
"dd": (date: Date) => padRight(date.getUTCDate().toString(), '0', 2),
|
"dd": (date: Date) => padRight(date.getUTCDate().toString(), '0', 2),
|
||||||
"mm": (date: Date) => padRight(date.getFullYear().toString(), '0', 2),
|
"mm": (date: Date) => padRight(date.getUTCMinutes().toString(), '0', 2),
|
||||||
"HH": (date: Date) => padRight(date.getFullYear().toString(), '0', 2),
|
"HH": (date: Date) => padRight(date.getUTCHours().toString(), '0', 2),
|
||||||
"ss": (date: Date) => padRight(date.getFullYear().toString(), '0', 2),
|
"ss": (date: Date) => padRight(date.getUTCSeconds().toString(), '0', 2),
|
||||||
};
|
};
|
||||||
const keys = Object.keys(transforms);
|
const keys = Object.keys(transforms);
|
||||||
|
|
||||||
@@ -39,4 +45,11 @@ export function clamp(x: number, min: number, max: number): number {
|
|||||||
if (x < min)
|
if (x < min)
|
||||||
return min;
|
return min;
|
||||||
return x;
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const iconByType: Record<FileType, string> = {
|
||||||
|
'folder': 'lucide:folder',
|
||||||
|
'canvas': 'ph:graph-light',
|
||||||
|
'file': 'radix-icons:file',
|
||||||
|
'markdown': 'radix-icons:file',
|
||||||
}
|
}
|
||||||
2
types/auth.d.ts
vendored
2
types/auth.d.ts
vendored
@@ -31,6 +31,8 @@ export interface UserRawData {
|
|||||||
|
|
||||||
export interface UserExtendedData {
|
export interface UserExtendedData {
|
||||||
signin: Date;
|
signin: Date;
|
||||||
|
lastTimestamp: Date;
|
||||||
|
logCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Permissions = { permissions: string[] };
|
export type Permissions = { permissions: string[] };
|
||||||
|
|||||||
Reference in New Issue
Block a user