You've already forked obsidian-visualiser
Compare commits
45 Commits
rework
...
b24a083d2e
| Author | SHA1 | Date | |
|---|---|---|---|
| b24a083d2e | |||
| ad61dc8897 | |||
| 1e8afe90dd | |||
| 8439d3444f | |||
| 36909c5d66 | |||
| fea37e2f59 | |||
| a3d9e466a5 | |||
| 9c69ff2903 | |||
| 3b919075ef | |||
| 4150b69ba3 | |||
| 298f47a280 | |||
| 161f0d856a | |||
| 51a5d501be | |||
| ecdfa947ac | |||
| fd951c294f | |||
| 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 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ logs
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
bun.lockb
|
||||
db.sqlite
|
||||
db.sqlite-wal
|
||||
db.sqlite-shm
|
||||
3
app.vue
3
app.vue
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
|
||||
<NuxtRouteAnnouncer/>
|
||||
<NuxtLoadingIndicator />
|
||||
<TooltipProvider>
|
||||
<NuxtLayout>
|
||||
<div class="xl:ps-12 xl:pe-12 ps-6 pe-4 flex flex-1 justify-center overflow-auto max-h-full relative">
|
||||
<div class="xl:px-12 xl:py-8 lg:px-8 lg:py-6 px-6 py-3 flex flex-1 justify-center overflow-auto max-h-full relative">
|
||||
<NuxtPage></NuxtPage>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<script lang="ts">
|
||||
const External = Annotation.define<boolean>();
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dropCursor, crosshairCursor, keymap, EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { dropCursor, crosshairCursor, keymap, EditorView, ViewUpdate, placeholder as placeholderExtension } from '@codemirror/view';
|
||||
import { Annotation, EditorState } from '@codemirror/state';
|
||||
import { indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldKeymap } from '@codemirror/language';
|
||||
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { lintKeymap } from '@codemirror/lint';
|
||||
|
||||
@@ -11,6 +15,9 @@ const editor = useTemplateRef('editor');
|
||||
const view = ref<EditorView>();
|
||||
const state = ref<EditorState>();
|
||||
|
||||
const { placeholder } = defineProps<{
|
||||
placeholder?: string
|
||||
}>();
|
||||
const model = defineModel<string>();
|
||||
|
||||
onMounted(() => {
|
||||
@@ -20,6 +27,7 @@ onMounted(() => {
|
||||
doc: model.value,
|
||||
extensions: [
|
||||
history(),
|
||||
search(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
@@ -27,6 +35,7 @@ onMounted(() => {
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
crosshairCursor(),
|
||||
placeholderExtension(placeholder ?? ''),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
@@ -36,7 +45,14 @@ onMounted(() => {
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap
|
||||
])
|
||||
]),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
|
||||
{
|
||||
model.value = viewUpdate.state.doc.toString();
|
||||
}
|
||||
}),
|
||||
EditorView.contentAttributes.of({spellcheck: "true"}),
|
||||
]
|
||||
});
|
||||
view.value = new EditorView({
|
||||
@@ -60,16 +76,15 @@ watchEffect(() => {
|
||||
const currentValue = view.value ? view.value.state.doc.toString() : "";
|
||||
if (view.value && model.value !== currentValue) {
|
||||
view.value.dispatch({
|
||||
changes: { from: 0, to: currentValue.length, insert: model.value || "" }
|
||||
changes: { from: 0, to: currentValue.length, insert: model.value || "" },
|
||||
annotations: [External.of(true)],
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 justify-center items-start p-12">
|
||||
<div ref="editor" class="flex flex-1 justify-center items-stretch border border-light-35 dark:border-dark-35 caret-light-100 dark:caret-dark-100" />
|
||||
</div>
|
||||
<div ref="editor" class="flex flex-1 w-full justify-stretch items-stretch border border-light-35 dark:border-dark-35 caret-light-100 dark:caret-dark-100" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Text, Comment } from 'vue';
|
||||
import ProseP from '~/components/prose/ProseP.vue';
|
||||
import ProseA from '~/components/prose/ProseA.vue';
|
||||
import ProseBlockquote from '~/components/prose/ProseBlockquote.vue';
|
||||
import ProseCallout from './prose/ProseCallout.vue';
|
||||
import ProseCode from '~/components/prose/ProseCode.vue';
|
||||
import ProsePre from '~/components/prose/ProsePre.vue';
|
||||
import ProseEm from '~/components/prose/ProseEm.vue';
|
||||
@@ -19,6 +20,7 @@ import ProseImg from '~/components/prose/ProseImg.vue';
|
||||
import ProseUl from '~/components/prose/ProseUl.vue';
|
||||
import ProseOl from '~/components/prose/ProseOl.vue';
|
||||
import ProseLi from '~/components/prose/ProseLi.vue';
|
||||
import ProseSmall from './prose/ProseSmall.vue';
|
||||
import ProseStrong from '~/components/prose/ProseStrong.vue';
|
||||
import ProseTable from '~/components/prose/ProseTable.vue';
|
||||
import ProseTag from '~/components/prose/ProseTag.vue';
|
||||
@@ -33,6 +35,7 @@ const proseList = {
|
||||
"p": ProseP,
|
||||
"a": ProseA,
|
||||
"blockquote": ProseBlockquote,
|
||||
"callout": ProseCallout,
|
||||
"code": ProseCode,
|
||||
"pre": ProsePre,
|
||||
"em": ProseEm,
|
||||
@@ -47,6 +50,7 @@ const proseList = {
|
||||
"ul": ProseUl,
|
||||
"ol": ProseOl,
|
||||
"li": ProseLi,
|
||||
"small": ProseSmall,
|
||||
"strong": ProseStrong,
|
||||
"table": ProseTable,
|
||||
"tag": ProseTag,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<CollapsibleRoot v-model:open="model" :disabled="disabled">
|
||||
<CollapsibleRoot v-model:open="model" :disabled="disabled" :defaultOpen="defaultOpen">
|
||||
<div class="flex flex-row justify-center items-center">
|
||||
<span v-if="!!label">{{ label }}</span>
|
||||
<CollapsibleTrigger class="ms-4" asChild>
|
||||
@@ -18,9 +18,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
const { label, disabled = false } = defineProps<{
|
||||
const { label, disabled = false, defaultOpen = false } = defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
defaultOpen?: boolean
|
||||
}>();
|
||||
const model = defineModel<boolean>();
|
||||
</script>
|
||||
|
||||
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 text-light-100 dark:text-dark-100 overflow-auto max-h-full">
|
||||
<DraggableTreeItem v-for="item in flattenItems" :key="item._id" v-bind="item" class="flex items-center outline-none relative cursor-pointer hover:bg-light-20 dark:hover:bg-dark-20 data-[selected]:bg-light-35 dark:data-[selected]:bg-dark-35" @select.prevent @toggle.prevent>
|
||||
<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>
|
||||
<HoverCardRoot :open-delay="delay">
|
||||
<HoverCardRoot :open-delay="delay" @update:open="(...args) => emits('open', ...args)">
|
||||
<HoverCardTrigger class="inline-block cursor-help outline-none">
|
||||
<slot></slot>
|
||||
</HoverCardTrigger>
|
||||
@@ -18,4 +18,6 @@ const { delay = 500, disabled = false, side = 'bottom' } = defineProps<{
|
||||
disabled?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>();
|
||||
|
||||
const emits = defineEmits(['open'])
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Label class="py-4 flex flex-row justify-center items-center">
|
||||
<span>{{ label }}</span>
|
||||
<Label class="my-2 flex flex-1 items-center justify-between flex-col md:flex-row">
|
||||
<span class="pb-1 md:p-0">{{ label }}</span>
|
||||
<SelectRoot v-model="model">
|
||||
<SelectTrigger :disabled="disabled" class="mx-4 inline-flex min-w-[160px] items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1
|
||||
bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none data-[placeholder]:font-normal
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<Label class="flex justify-center items-center my-2">{{ label }}
|
||||
<Label class="flex justify-center items-center my-2">
|
||||
<span class="md:text-base text-sm">{{ label }}</span>
|
||||
<SwitchRoot v-model:checked="model" :disabled="disabled"
|
||||
class="group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none
|
||||
data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
|
||||
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
|
||||
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20"
|
||||
:type="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs">
|
||||
:type="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs" @change="(e) => emits('change', e)" @input="(e) => emits('input', e)">
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
@@ -16,5 +16,10 @@ const { type = 'text', label, disabled = false, placeholder } = defineProps<{
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
change: [Event]
|
||||
input: [Event]
|
||||
}>();
|
||||
const model = defineModel<string>();
|
||||
</script>
|
||||
@@ -1,50 +1,20 @@
|
||||
<template>
|
||||
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm" :items="model" :get-key="(item) => item.link ?? item.label">
|
||||
<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">
|
||||
<NuxtLink :href="item.value.link && !item.hasChildren ? { name: 'explore-path', params: { path: item.value.link } } : undefined" no-prefetch class="flex flex-1 items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" :class="{ 'border-s': !item.hasChildren, 'font-medium': item.hasChildren }" active-class="text-accent-blue border-s-2 !border-accent-blue">
|
||||
<Icon v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level - 1}em` }" />
|
||||
<div class="pl-3 py-1 flex-1 truncate" :data-tag="item.value.tag">
|
||||
{{ item.value.label }}
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<slot :isExpanded="isExpanded" :item="item" />
|
||||
</TreeItem>
|
||||
</TreeRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
const { getKey } = defineProps<{
|
||||
getKey: (val: T) => string
|
||||
}>();
|
||||
|
||||
interface TreeItem
|
||||
const model = defineModel<T[]>();
|
||||
|
||||
function flatten(arr: T[]): string[]
|
||||
{
|
||||
label: string
|
||||
link?: string
|
||||
tag?: string
|
||||
children?: TreeItem[]
|
||||
return arr.filter(e => e.open).flatMap(e => [getKey(e), ...flatten(e.children ?? [])]);
|
||||
}
|
||||
const model = defineModel<TreeItem[]>();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[data-tag="canvas"]:after,
|
||||
[data-tag="private"]:after
|
||||
{
|
||||
@apply text-sm;
|
||||
@apply font-normal;
|
||||
@apply float-end;
|
||||
@apply border ;
|
||||
@apply border-light-35 ;
|
||||
@apply dark:border-dark-35;
|
||||
@apply px-1;
|
||||
@apply bg-light-20;
|
||||
@apply dark:bg-dark-20;
|
||||
font-variant: small-caps;
|
||||
}
|
||||
[data-tag="canvas"]:after
|
||||
{
|
||||
content: 'Canvas'
|
||||
}
|
||||
[data-tag="private"]:after
|
||||
{
|
||||
content: 'Privé'
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useDrag, usePinch, useWheel } from '@vueuse/gesture';
|
||||
import type { CanvasContent, CanvasNode } from '~/types/canvas';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import { clamp } from '#imports';
|
||||
import { clamp } from '#shared/general.utils';
|
||||
|
||||
interface Props
|
||||
{
|
||||
@@ -150,24 +150,20 @@ dark:border-dark-purple
|
||||
|
||||
*/
|
||||
|
||||
const dragHandler = useDrag(({ event: Event, delta: [x, y] }: { event: Event, delta: number[] }) => {
|
||||
event?.preventDefault();
|
||||
const pinchHandler = usePinch(({ event, offset: [z] }: { event: Event, offset: number[] }) => {
|
||||
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;
|
||||
dispY.value += y / zoom.value;
|
||||
}, {
|
||||
domTarget: canvas,
|
||||
eventOptions: { passive: false, }
|
||||
})
|
||||
const pinchHandler = usePinch(({ event: Event, offset: [z] }: { event: Event, offset: number[] }) => {
|
||||
event?.preventDefault();
|
||||
console.log(z);
|
||||
zoom.value = clamp(z / 2048, minZoom.value, 3);
|
||||
}, {
|
||||
domTarget: canvas,
|
||||
eventOptions: { passive: false, }
|
||||
})
|
||||
const wheelHandler = useWheel(({ event: Event, delta: [x, y] }: { event: Event, delta: number[] }) => {
|
||||
event?.preventDefault();
|
||||
const wheelHandler = useWheel(({ event, delta: [x, y] }: { event: Event, delta: number[] }) => {
|
||||
zoom.value = clamp(zoom.value + y * -0.001, minZoom.value, 3);
|
||||
}, {
|
||||
domTarget: canvas,
|
||||
@@ -180,7 +176,7 @@ const wheelHandler = useWheel(({ event: Event, delta: [x, y] }: { event: Event,
|
||||
<template #default>
|
||||
<div id="canvas" ref="canvas" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none"
|
||||
:style="{ '--zoom-multiplier': (1 / Math.pow(zoom, 0.7)) }">
|
||||
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 absolute sm:top-2 top-10 left-2 z-30 overflow-hidden">
|
||||
<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">
|
||||
<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" />
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
<template>
|
||||
<NuxtLink no-prefetch class="text-accent-blue inline-flex items-center" v-if="data && data[0]"
|
||||
:to="{ name: 'explore-path', params: { path: data[0].path }, hash: hash }" :class="class">
|
||||
<HoverCard class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': data[0].type === 'canvas'}">
|
||||
<NuxtLink class="text-accent-blue inline-flex items-center" v-if="overview"
|
||||
: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': overview.type === 'canvas'}" @open="load">
|
||||
<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">
|
||||
<Markdown :content="data[0].content" />
|
||||
<Markdown :content="content!.content" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="data[0].type === 'canvas'">
|
||||
<template v-else-if="overview.type === 'canvas'">
|
||||
<div class="w-[600px] h-[600px] relative">
|
||||
<Canvas :canvas="JSON.parse(data[0].content)" />
|
||||
<Canvas :canvas="JSON.parse(content!.content)" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #default>
|
||||
<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>
|
||||
</HoverCard>
|
||||
</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>
|
||||
<Icon class="w-4 h-4 inline-block" v-if="data && data[0] && data[0].type !== 'markdown'" :height="20" :width="20"
|
||||
:icon="`icons/link-${data[0].type.toLowerCase()}`" />
|
||||
<Icon class="w-4 h-4 inline-block" v-if="overview && overview.type !== 'markdown'" :height="20" :width="20" :icon="iconByType[overview.type]" />
|
||||
</NuxtLink>
|
||||
<slot :class="class" v-else v-bind="$attrs"></slot>
|
||||
</template>
|
||||
@@ -31,29 +33,43 @@
|
||||
<script setup lang="ts">
|
||||
import { parseURL } from 'ufo';
|
||||
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<{
|
||||
href: string
|
||||
class?: string
|
||||
}>();
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
try {
|
||||
data.value = await $fetch(`/api/file`, {
|
||||
query: {
|
||||
search: `%${pathname}`
|
||||
},
|
||||
});
|
||||
content.value = await $fetch(`/api/file/content/${encodeURIComponent(pathname)}`);
|
||||
} catch(e) { }
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -1,179 +1,5 @@
|
||||
<template>
|
||||
<blockquote ref="el">
|
||||
<blockquote class="empty:before:hidden ps-4 my-4 relative before:absolute before:-top-1 before:-bottom-1 before:left-0 before:w-1 before:bg-light-30 dark:before:bg-dark-30" ref="el">
|
||||
<slot />
|
||||
</blockquote>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const attrs = useAttrs(), el = ref<HTMLQuoteElement>(), title = ref<Element | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
if(el && el.value && attrs.hasOwnProperty("dataCalloutFold"))
|
||||
{
|
||||
title.value = el.value.querySelector('.callout-title');
|
||||
title.value?.addEventListener('click', toggle);
|
||||
}
|
||||
});
|
||||
onUnmounted(() => {
|
||||
title.value?.removeEventListener('click', toggle);
|
||||
})
|
||||
function toggle() {
|
||||
el.value?.classList?.toggle('is-collapsed');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
blockquote:not(.callout)
|
||||
{
|
||||
@apply ps-4;
|
||||
@apply my-4;
|
||||
@apply relative;
|
||||
@apply before:absolute;
|
||||
@apply before:-top-1;
|
||||
@apply before:-bottom-1;
|
||||
@apply before:left-0;
|
||||
@apply before:w-1;
|
||||
@apply before:bg-light-30;
|
||||
@apply dark:before:bg-dark-30;
|
||||
}
|
||||
blockquote:empty
|
||||
{
|
||||
@apply before:hidden;
|
||||
}
|
||||
.callout {
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
}
|
||||
.callout.is-collapsible .callout-title
|
||||
{
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
.callout .fold
|
||||
{
|
||||
@apply transition-transform;
|
||||
}
|
||||
.callout.is-collapsed .fold
|
||||
{
|
||||
@apply -rotate-90;
|
||||
}
|
||||
.callout.is-collapsed > p
|
||||
{
|
||||
@apply hidden;
|
||||
}
|
||||
.callout[datacallout="abstract"],
|
||||
.callout[datacallout="summary"],
|
||||
.callout[datacallout="tldr"] {
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[datacallout="info"] {
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
@apply text-light-blue;
|
||||
@apply dark:text-dark-blue;
|
||||
}
|
||||
.callout[datacallout="todo"] {
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
@apply text-light-blue;
|
||||
@apply dark:text-dark-blue;
|
||||
}
|
||||
.callout[datacallout="important"] {
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[datacallout="tip"],
|
||||
.callout[datacallout="hint"] {
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[datacallout="success"],
|
||||
.callout[datacallout="check"],
|
||||
.callout[datacallout="done"] {
|
||||
@apply bg-light-green;
|
||||
@apply dark:bg-dark-green;
|
||||
@apply text-light-green;
|
||||
@apply dark:text-dark-green;
|
||||
}
|
||||
.callout[datacallout="question"],
|
||||
.callout[datacallout="help"],
|
||||
.callout[datacallout="faq"] {
|
||||
@apply bg-light-orange;
|
||||
@apply dark:bg-dark-orange;
|
||||
@apply text-light-orange;
|
||||
@apply dark:text-dark-orange;
|
||||
}
|
||||
.callout[datacallout="warning"],
|
||||
.callout[datacallout="caution"],
|
||||
.callout[datacallout="attention"] {
|
||||
@apply bg-light-orange;
|
||||
@apply dark:bg-dark-orange;
|
||||
@apply text-light-orange;
|
||||
@apply dark:text-dark-orange;
|
||||
}
|
||||
.callout[datacallout="failure"],
|
||||
.callout[datacallout="fail"],
|
||||
.callout[datacallout="missing"] {
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[datacallout="danger"],
|
||||
.callout[datacallout="error"] {
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[datacallout="bug"] {
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[datacallout="example"] {
|
||||
@apply bg-light-purple;
|
||||
@apply dark:bg-dark-purple;
|
||||
@apply text-light-purple;
|
||||
@apply dark:text-dark-purple;
|
||||
}
|
||||
|
||||
.callout
|
||||
{
|
||||
@apply overflow-hidden;
|
||||
@apply my-4;
|
||||
@apply p-3;
|
||||
@apply ps-6;
|
||||
@apply bg-blend-lighten;
|
||||
@apply !bg-opacity-25;
|
||||
@apply border-l-4;
|
||||
@apply inline-block;
|
||||
@apply pe-8;
|
||||
}
|
||||
.callout-icon
|
||||
{
|
||||
@apply w-6;
|
||||
@apply h-6;
|
||||
@apply stroke-2;
|
||||
@apply float-start;
|
||||
@apply me-2;
|
||||
}
|
||||
.callout-title-inner
|
||||
{
|
||||
@apply block;
|
||||
@apply font-bold;
|
||||
@apply ps-8;
|
||||
}
|
||||
.callout > p
|
||||
{
|
||||
@apply mt-2;
|
||||
@apply font-semibold;
|
||||
}
|
||||
</style>
|
||||
146
components/prose/ProseCallout.vue
Normal file
146
components/prose/ProseCallout.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<CollapsibleRoot :disabled="disabled" :defaultOpen="fold === true || fold === undefined" class="callout group overflow-hidden my-4 p-3 ps-4 bg-blend-lighten !bg-opacity-25 border-l-4 inline-block pe-8 bg-light-blue dark:bg-dark-blue" :data-type="type">
|
||||
<CollapsibleTrigger>
|
||||
<div :class="{ 'cursor-pointer': fold !== undefined }" class="flex flex-row items-center justify-start ps-2">
|
||||
<Icon :icon="calloutIconByType[type] ?? defaultCalloutIcon" class="w-6 h-6 stroke-2 float-start me-2" />
|
||||
<span v-if="title" class="block font-bold">{{ title }}</span>
|
||||
<Icon icon="radix-icons:caret-right" v-if="fold !== undefined" class="transition-transform group-data-[state=open]:rotate-90 w-6 h-6 mx-6" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="overflow-hidden data-[state=closed]:animate-[collapseClose_0.2s_ease-in-out] data-[state=open]:animate-[collapseOpen_0.2s_ease-in-out] data-[state=closed]:h-0">
|
||||
<div class="px-2">
|
||||
<slot />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const calloutIconByType: Record<string, string> = {
|
||||
note: 'radix-icons:pencil-1',
|
||||
abstract: 'radix-icons:file-text',
|
||||
info: 'radix-icons:info-circled',
|
||||
todo: 'radix-icons:check-circled',
|
||||
tip: 'radix-icons:star',
|
||||
success: 'radix-icons:check',
|
||||
question: 'radix-icons:question-mark-circled',
|
||||
warning: 'radix-icons:exclamation-triangle',
|
||||
failure: 'radix-icons:cross-circled',
|
||||
danger: 'radix-icons:circle-backslash',
|
||||
bug: 'solar:bug-linear',
|
||||
example: 'radix-icons:list-bullet',
|
||||
quote: 'radix-icons:quote',
|
||||
};
|
||||
const defaultCalloutIcon = 'radix-icons:info-circled';
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
const { type, title, fold } = defineProps<{
|
||||
type: string;
|
||||
title?: string;
|
||||
fold?: boolean;
|
||||
}>();
|
||||
const disabled = computed(() => fold === undefined);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.callout[data-type="abstract"],
|
||||
.callout[data-type="summary"],
|
||||
.callout[data-type="tldr"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="info"]
|
||||
{
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
@apply text-light-blue;
|
||||
@apply dark:text-dark-blue;
|
||||
}
|
||||
.callout[data-type="todo"]
|
||||
{
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
@apply text-light-blue;
|
||||
@apply dark:text-dark-blue;
|
||||
}
|
||||
.callout[data-type="important"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="tip"],
|
||||
.callout[data-type="hint"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="success"],
|
||||
.callout[data-type="check"],
|
||||
.callout[data-type="done"]
|
||||
{
|
||||
@apply bg-light-green;
|
||||
@apply dark:bg-dark-green;
|
||||
@apply text-light-green;
|
||||
@apply dark:text-dark-green;
|
||||
}
|
||||
.callout[data-type="question"],
|
||||
.callout[data-type="help"],
|
||||
.callout[data-type="faq"]
|
||||
{
|
||||
@apply bg-light-orange;
|
||||
@apply dark:bg-dark-orange;
|
||||
@apply text-light-orange;
|
||||
@apply dark:text-dark-orange;
|
||||
}
|
||||
.callout[data-type="warning"],
|
||||
.callout[data-type="caution"],
|
||||
.callout[data-type="attention"]
|
||||
{
|
||||
@apply bg-light-orange;
|
||||
@apply dark:bg-dark-orange;
|
||||
@apply text-light-orange;
|
||||
@apply dark:text-dark-orange;
|
||||
}
|
||||
.callout[data-type="failure"],
|
||||
.callout[data-type="fail"],
|
||||
.callout[data-type="missing"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="danger"],
|
||||
.callout[data-type="error"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="bug"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="example"]
|
||||
{
|
||||
@apply bg-light-purple;
|
||||
@apply dark:bg-dark-purple;
|
||||
@apply text-light-purple;
|
||||
@apply dark:text-dark-purple;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -5,5 +5,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseId } from '#shared/general.utils';
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<template>
|
||||
<Separator class="border-light-35 dark:border-dark-35 m-4" />
|
||||
<Separator class="border-b border-light-35 dark:border-dark-35 m-4" />
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
<template>
|
||||
<p><slot /></p>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.text-comment
|
||||
{
|
||||
@apply text-light-50;
|
||||
@apply dark:text-dark-50;
|
||||
@apply text-sm;
|
||||
}
|
||||
</style>
|
||||
5
components/prose/ProseSmall.vue
Normal file
5
components/prose/ProseSmall.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<small class="text-light-60 dark:text-dark-60 text-sm italic">
|
||||
<slot />
|
||||
</small>
|
||||
</template>
|
||||
@@ -1,57 +1,5 @@
|
||||
<template>
|
||||
<!-- <HoverPopup @before-show="fetch">
|
||||
<template #content>
|
||||
<Suspense suspensible>
|
||||
<div class="mw-[400px]">
|
||||
<div v-if="fetched === false" class="loading w-[400px] h-[150px]"></div>
|
||||
<template v-else-if="!!data">
|
||||
<div v-if="data.description" class="pb-4 pt-3 px-8">
|
||||
<span class="text-2xl font-semibold">#{{ data.tag }}</span>
|
||||
<Markdown :content="data.description"></Markdown>
|
||||
</div>
|
||||
<div class="h-100 w-100 flex flex-1 flex-col justify-center items-center" v-else>
|
||||
<div class="text-3xl font-extralight tracking-wide text-light-60 dark:text-dark-60">Fichier vide</div>
|
||||
<div class="text-lg text-light-60 dark:text-dark-60">Cette page est vide</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="h-100 w-100 flex flex-1 flex-col justify-center items-center" v-else>
|
||||
<div class="text-3xl font-extralight tracking-wide text-light-60 dark:text-dark-60">Impossible d'afficher</div>
|
||||
<div class="text-lg text-light-60 dark:text-dark-60">Cette page est impossible à traiter</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #fallback><div class="loading w-[400px] h-[150px]"></div></template>
|
||||
</Suspense>
|
||||
</template>
|
||||
<template #default>
|
||||
<span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</template>
|
||||
</HoverPopup> -->
|
||||
<span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- <script setup lang="ts">
|
||||
import type { Tag } from '~/types/api';
|
||||
|
||||
const { tag } = defineProps({
|
||||
tag: {
|
||||
type: String,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const data = ref<Tag>(), fetched = ref(false);
|
||||
const route = useRoute();
|
||||
const project = computed(() => parseInt(Array.isArray(route.params.projectId) ? '0' : route.params.projectId));
|
||||
async function fetch()
|
||||
{
|
||||
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 sqlite = new Database(database);
|
||||
instance = drizzle({ client: sqlite, schema });
|
||||
instance = drizzle({ client: sqlite, schema, /* logger: true */ });
|
||||
|
||||
instance.run("PRAGMA journal_mode = WAL;");
|
||||
instance.run("PRAGMA foreign_keys = true;");
|
||||
|
||||
@@ -4,10 +4,8 @@ import RemarkParse from "remark-parse";
|
||||
|
||||
import RemarkRehype from 'remark-rehype';
|
||||
import RemarkOfm from 'remark-ofm';
|
||||
import RemarkBreaks from 'remark-breaks'
|
||||
import RemarkGfm from 'remark-gfm';
|
||||
import RemarkFrontmatter from 'remark-frontmatter';
|
||||
import RehypeRaw from 'rehype-raw';
|
||||
|
||||
export default function useMarkdown(): (md: string) => Root
|
||||
{
|
||||
@@ -16,9 +14,8 @@ export default function useMarkdown(): (md: string) => Root
|
||||
const parse = (markdown: string) => {
|
||||
if (!processor)
|
||||
{
|
||||
processor = unified().use([RemarkParse, RemarkGfm , RemarkOfm , RemarkBreaks, RemarkFrontmatter]);
|
||||
processor.use(RemarkRehype, { allowDangerousHtml: true });
|
||||
processor.use(RehypeRaw);
|
||||
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkFrontmatter]);
|
||||
processor.use(RemarkRehype);
|
||||
}
|
||||
|
||||
const processed = processor.runSync(processor.parse(markdown)) as Root;
|
||||
|
||||
194
composables/useShortcuts.ts
Normal file
194
composables/useShortcuts.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ComputedRef, WatchSource } from 'vue'
|
||||
import { logicAnd, logicNot } from '@vueuse/math'
|
||||
import { useEventListener, useDebounceFn, createSharedComposable, useActiveElement } from '@vueuse/core'
|
||||
|
||||
export interface ShortcutConfig {
|
||||
handler: Function
|
||||
usingInput?: string | boolean
|
||||
whenever?: WatchSource<boolean>[]
|
||||
prevent?: boolean
|
||||
}
|
||||
|
||||
export interface ShortcutsConfig {
|
||||
[key: string]: ShortcutConfig | Function
|
||||
}
|
||||
|
||||
export interface ShortcutsOptions {
|
||||
chainDelay?: number
|
||||
}
|
||||
|
||||
interface Shortcut {
|
||||
handler: Function
|
||||
condition: ComputedRef<boolean>
|
||||
chained: boolean
|
||||
// KeyboardEvent attributes
|
||||
key: string
|
||||
ctrlKey: boolean
|
||||
metaKey: boolean
|
||||
shiftKey: boolean
|
||||
altKey: boolean
|
||||
// code?: string
|
||||
// keyCode?: number
|
||||
prevent?: boolean
|
||||
}
|
||||
|
||||
const chainedShortcutRegex = /^[^-]+.*-.*[^-]+$/
|
||||
const combinedShortcutRegex = /^[^_]+.*_.*[^_]+$/
|
||||
|
||||
export const useShortcuts = (config: ShortcutsConfig, options: ShortcutsOptions = {}) => {
|
||||
const { macOS, usingInput } = _useShortcuts()
|
||||
|
||||
let shortcuts: Shortcut[] = []
|
||||
|
||||
const chainedInputs = ref<string[]>([])
|
||||
const clearChainedInput = () => {
|
||||
chainedInputs.value.splice(0, chainedInputs.value.length)
|
||||
}
|
||||
const debouncedClearChainedInput = useDebounceFn(clearChainedInput, options.chainDelay ?? 800)
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// Input autocomplete triggers a keydown event
|
||||
if (!e.key) { return }
|
||||
|
||||
const alphabeticalKey = /^[a-z]{1}$/i.test(e.key)
|
||||
|
||||
let chainedKey
|
||||
chainedInputs.value.push(e.key)
|
||||
// try matching a chained shortcut
|
||||
if (chainedInputs.value.length >= 2) {
|
||||
chainedKey = chainedInputs.value.slice(-2).join('-')
|
||||
|
||||
for (const shortcut of shortcuts.filter(s => s.chained)) {
|
||||
if (shortcut.key !== chainedKey) { continue }
|
||||
|
||||
if (shortcut.condition.value) {
|
||||
e.stopPropagation
|
||||
shortcut.prevent && e.preventDefault()
|
||||
shortcut.handler()
|
||||
}
|
||||
clearChainedInput()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// try matching a standard shortcut
|
||||
for (const shortcut of shortcuts.filter(s => !s.chained)) {
|
||||
if (e.key.toLowerCase() !== shortcut.key) { continue }
|
||||
if (e.metaKey !== shortcut.metaKey) { continue }
|
||||
if (e.ctrlKey !== shortcut.ctrlKey) { continue }
|
||||
// shift modifier is only checked in combination with alphabetical keys
|
||||
// (shift with non-alphabetical keys would change the key)
|
||||
if (alphabeticalKey && e.shiftKey !== shortcut.shiftKey) { continue }
|
||||
// alt modifier changes the combined key anyways
|
||||
// if (e.altKey !== shortcut.altKey) { continue }
|
||||
|
||||
if (shortcut.condition.value) {
|
||||
e.preventDefault()
|
||||
shortcut.handler()
|
||||
}
|
||||
clearChainedInput()
|
||||
return
|
||||
}
|
||||
|
||||
debouncedClearChainedInput()
|
||||
}
|
||||
|
||||
// Map config to full detailled shortcuts
|
||||
shortcuts = Object.entries(config).map(([key, shortcutConfig]) => {
|
||||
if (!shortcutConfig) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse key and modifiers
|
||||
let shortcut: Partial<Shortcut>
|
||||
|
||||
if (key.includes('-') && key !== '-' && !key.match(chainedShortcutRegex)?.length) {
|
||||
console.trace(`[Shortcut] Invalid key: "${key}"`)
|
||||
}
|
||||
|
||||
if (key.includes('_') && key !== '_' && !key.match(combinedShortcutRegex)?.length) {
|
||||
console.trace(`[Shortcut] Invalid key: "${key}"`)
|
||||
}
|
||||
|
||||
const chained = key.includes('-') && key !== '-'
|
||||
if (chained) {
|
||||
shortcut = {
|
||||
key: key.toLowerCase(),
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false
|
||||
}
|
||||
} else {
|
||||
const keySplit = key.toLowerCase().split('_').map(k => k)
|
||||
shortcut = {
|
||||
key: keySplit.filter(k => !['meta', 'ctrl', 'shift', 'alt'].includes(k)).join('_'),
|
||||
metaKey: keySplit.includes('meta'),
|
||||
ctrlKey: keySplit.includes('ctrl'),
|
||||
shiftKey: keySplit.includes('shift'),
|
||||
altKey: keySplit.includes('alt')
|
||||
}
|
||||
}
|
||||
shortcut.chained = chained
|
||||
|
||||
// Convert Meta to Ctrl for non-MacOS
|
||||
if (!macOS.value && shortcut.metaKey && !shortcut.ctrlKey) {
|
||||
shortcut.metaKey = false
|
||||
shortcut.ctrlKey = true
|
||||
}
|
||||
|
||||
// Retrieve handler function
|
||||
if (typeof shortcutConfig === 'function') {
|
||||
shortcut.handler = shortcutConfig
|
||||
} else if (typeof shortcutConfig === 'object') {
|
||||
shortcut = { ...shortcut, handler: shortcutConfig.handler, prevent: shortcutConfig.prevent }
|
||||
}
|
||||
|
||||
if (!shortcut.handler) {
|
||||
console.trace('[Shortcut] Invalid value')
|
||||
return null
|
||||
}
|
||||
|
||||
// Create shortcut computed
|
||||
const conditions: ComputedRef<boolean>[] = []
|
||||
if (!(shortcutConfig as ShortcutConfig).usingInput) {
|
||||
conditions.push(logicNot(usingInput))
|
||||
} else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') {
|
||||
conditions.push(computed(() => usingInput.value === (shortcutConfig as ShortcutConfig).usingInput))
|
||||
}
|
||||
shortcut.condition = logicAnd(...conditions, ...((shortcutConfig as ShortcutConfig).whenever || []))
|
||||
|
||||
return shortcut as Shortcut
|
||||
}).filter(Boolean) as Shortcut[]
|
||||
|
||||
useEventListener('keydown', onKeyDown)
|
||||
}
|
||||
|
||||
export const _useShortcuts = () => {
|
||||
const macOS = computed(() => process.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/))
|
||||
|
||||
const metaSymbol = ref(' ')
|
||||
|
||||
const activeElement = useActiveElement()
|
||||
const usingInput = computed(() => {
|
||||
const usingInput = !!(activeElement.value?.tagName === 'INPUT' || activeElement.value?.tagName === 'TEXTAREA' || activeElement.value?.contentEditable === 'true')
|
||||
|
||||
if (usingInput) {
|
||||
return ((activeElement.value as any)?.name as string) || true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
metaSymbol.value = macOS.value ? '⌘' : 'Ctrl'
|
||||
})
|
||||
|
||||
return {
|
||||
macOS,
|
||||
metaSymbol,
|
||||
activeElement,
|
||||
usingInput
|
||||
}
|
||||
}
|
||||
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", {
|
||||
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
lastTimestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
logCount: int().notNull().default(0),
|
||||
});
|
||||
|
||||
export const userSessionsTable = sqliteTable("user_sessions", {
|
||||
@@ -39,8 +41,11 @@ export const explorerContentTable = sqliteTable("explorer_content", {
|
||||
title: text().notNull(),
|
||||
type: text({ enum: ['file', 'folder', 'markdown', 'canvas'] }).notNull(),
|
||||
content: blob({ mode: 'buffer' }),
|
||||
navigable: int({ mode: 'boolean' }).default(true),
|
||||
private: int({ mode: 'boolean' }).default(false),
|
||||
navigable: int({ mode: 'boolean' }).notNull().default(true),
|
||||
private: int({ mode: 'boolean' }).notNull().default(false),
|
||||
order: int().notNull(),
|
||||
visit: int().notNull().default(0),
|
||||
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const 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,
|
||||
"tag": "0002_messy_solo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1731344368953,
|
||||
"tag": "0003_cultured_skaar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1732722840534,
|
||||
"tag": "0004_ancient_thunderball",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Erreur {{ error?.statusCode }}</Title>
|
||||
</Head>
|
||||
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden justify-center items-center flex-col gap-4">
|
||||
<NuxtRouteAnnouncer/>
|
||||
<div class="flex gap-4 items-center">
|
||||
<Icon icon="si:error-line" class="w-12 h-12 text-light-60 dark:text-dark-60"/>
|
||||
<div class="text-3xl">Une erreur est survenue.</div>
|
||||
</div>
|
||||
<pre class="">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
|
||||
<NuxtLink :href="{ name: 'index' }"><Button>Revenir en lieu sûr</Button></NuxtLink>
|
||||
<pre class="text-center text-wrap">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
|
||||
<Button @click="handleError">Revenir en lieu sûr</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,19 +12,26 @@
|
||||
</div>
|
||||
<div class="flex items-center px-2">
|
||||
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
|
||||
<Tooltip :message="loggedIn ? 'Mon profil' : 'Se connecter'" side="right">
|
||||
<NuxtLink class="" :to="{ name: 'user-profile' }">
|
||||
<div class="hover:border-opacity-70 flex">
|
||||
<Icon :icon="loggedIn ? 'radix-icons:avatar' : 'radix-icons:person'" class="w-7 h-7 p-1" />
|
||||
<Tooltip v-if="!loggedIn" :message="'Se connecter'" side="right">
|
||||
<NuxtLink :to="{ name: 'user-login' }">
|
||||
<div class="hover:border-opacity-70 flex items-center">
|
||||
<Icon :icon="'radix-icons:person'" class="w-7 h-7 p-1" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</Tooltip>
|
||||
<Tooltip v-else :message="'Mon profil'" side="right">
|
||||
<DropdownMenu :options="options" side="bottom" align="end">
|
||||
<div class="hover:border-opacity-70 flex items-center">
|
||||
<Icon :icon="'radix-icons:avatar'" class="w-7 h-7 p-1" />
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-row relative h-screen overflow-hidden">
|
||||
<CollapsibleContent asChild forceMount>
|
||||
<div class="bg-light-0 md:py-11 dark:bg-dark-0 z-40 xl:w-96 md:w-[15em] w-full border-r border-light-30 dark:border-dark-30 flex flex-col justify-between max-md:absolute max-md:-top-0 max-md:-bottom-0 md:left-0 max-md:data-[state=closed]:-left-full max-md:transition-[left] py-8 max-md:z-40 max-md:data-[state=open]:left-0">
|
||||
<div class="relative bottom-6 flex flex-col gap-4 xl:px-6 px-3">
|
||||
<div class="bg-light-0 dark:bg-dark-0 z-40 xl:w-96 md:w-[15em] w-full border-r border-light-30 dark:border-dark-30 flex flex-col justify-between max-md:absolute max-md:-top-0 max-md:-bottom-0 md:left-0 max-md:data-[state=closed]:-left-full max-md:transition-[left] max-md:z-40 max-md:data-[state=open]:left-0">
|
||||
<div class="flex flex-col gap-4 xl:px-6 px-3 py-4">
|
||||
<div class="flex justify-between items-center max-md:hidden">
|
||||
<NuxtLink class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-md:ps-6" aria-label="Accueil" :to="{ path: '/', force: true }">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
||||
@@ -32,19 +39,45 @@
|
||||
</NuxtLink>
|
||||
<div class="flex gap-4 items-center">
|
||||
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
|
||||
<Tooltip :message="loggedIn ? 'Mon profil' : 'Se connecter'" side="right">
|
||||
<NuxtLink class="" :to="{ name: 'user-profile' }">
|
||||
<Tooltip v-if="!loggedIn" :message="'Se connecter'" side="right">
|
||||
<NuxtLink :to="{ name: 'user-login' }">
|
||||
<div class="bg-light-20 dark:bg-dark-20 hover:border-opacity-70 flex border p-px border-light-50 dark:border-dark-50">
|
||||
<Icon :icon="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>
|
||||
</NuxtLink>
|
||||
</Tooltip>
|
||||
<Tooltip v-else :message="'Mon profil'" side="right">
|
||||
<DropdownMenu :options="options" side="right" align="start">
|
||||
<div class="bg-light-20 dark:bg-dark-20 hover:border-opacity-70 flex border p-px border-light-50 dark:border-dark-50">
|
||||
<Icon :icon="'radix-icons:avatar'" class="w-7 h-7 p-1" />
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tree v-if="pages" v-model="pages" class="flex-1 xl:px-6 px-3 max-w-full max-h-full overflow-y-auto overflow-x-hidden"/>
|
||||
<div class="xl:px-12 px-6 text-start text-xs text-light-60 dark:text-dark-60 relative top-4">
|
||||
<NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
||||
<div class="flex-1 xl:px-6 px-3 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex flex-row flex-1 justify-between items-center">
|
||||
<NuxtLink :href="{ name: 'explore-path', params: { path: 'index' } }" class="flex flex-1 font-bold text-lg items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" active-class="text-accent-blue border-s-2 !border-accent-blue">
|
||||
<span class="pl-3 py-1 flex-1 truncate">Projet</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="user && hasPermissions(user.permissions, ['admin', 'editor'])" :to="{ name: 'explore-edit' }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink>
|
||||
</div>
|
||||
<Tree v-if="pages" v-model="pages" :getKey="(item) => item.path">
|
||||
<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 py-4 text-center text-xs text-light-60 dark:text-dark-60">
|
||||
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
||||
<p>Copyright Peaceultime - 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,20 +89,38 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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';
|
||||
import { hasPermissions } from '~/shared/auth.util';
|
||||
|
||||
const open = ref(true);
|
||||
const { loggedIn } = useUserSession();
|
||||
const options = ref<DropdownOption[]>([{
|
||||
type: 'item',
|
||||
label: 'Mon profil',
|
||||
select: () => useRouter().push({ name: 'user-profile' }),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Deconnexion',
|
||||
select: () => clear(),
|
||||
}]);
|
||||
|
||||
const { data: pages } = await useLazyFetch('/api/navigation', {
|
||||
transform: transform,
|
||||
});
|
||||
const open = ref(false);
|
||||
const { loggedIn, user, clear } = useUserSession();
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -1,3 +1,3 @@
|
||||
<template>
|
||||
Index
|
||||
<slot></slot>
|
||||
</template>
|
||||
@@ -1,3 +1,5 @@
|
||||
import { hasPermissions } from "#shared/auth.util";
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const { loggedIn, fetch, user } = useUserSession();
|
||||
const meta = to.meta;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
import vuePlugin from 'rollup-plugin-vue'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2024-04-03',
|
||||
modules: [
|
||||
@@ -7,6 +9,7 @@ export default defineNuxtConfig({
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@vueuse/nuxt',
|
||||
'radix-vue/nuxt',
|
||||
'@nuxtjs/sitemap',
|
||||
],
|
||||
tailwindcss: {
|
||||
viewer: false,
|
||||
@@ -116,15 +119,29 @@ export default defineNuxtConfig({
|
||||
},
|
||||
],
|
||||
nitro: {
|
||||
preset: 'bun',
|
||||
experimental: {
|
||||
tasks: true,
|
||||
},
|
||||
rollupConfig: {
|
||||
external: ['bun'],
|
||||
plugins: [
|
||||
vuePlugin({ include: /\.vue$/, target: 'node' })
|
||||
]
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
session: {
|
||||
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
|
||||
},
|
||||
database: 'db.sqlite'
|
||||
database: 'db.sqlite',
|
||||
mail: {
|
||||
host: '',
|
||||
port: '',
|
||||
user: '',
|
||||
passwd: '',
|
||||
dkim: '',
|
||||
}
|
||||
},
|
||||
security: {
|
||||
rateLimiter: false,
|
||||
@@ -135,4 +152,18 @@ export default defineNuxtConfig({
|
||||
},
|
||||
xssValidator: false,
|
||||
},
|
||||
sitemap: {
|
||||
exclude: ['/admin/**', '/explore/edit', '/user/mailvalidated'],
|
||||
sources: ['/api/__sitemap__/urls']
|
||||
},
|
||||
experimental: {
|
||||
defaults: {
|
||||
nuxtLink: {
|
||||
prefetchOn: {
|
||||
interaction: false,
|
||||
visibility: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
42
package.json
42
package.json
@@ -3,27 +3,51 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "bun i && bunx nuxi cleanup",
|
||||
"predev": "bun i",
|
||||
"dev": "bunx --bun nuxi dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@iconify/vue": "^4.1.2",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@nuxtjs/sitemap": "^7.0.0",
|
||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||
"@vueuse/nuxt": "^11.1.0",
|
||||
"@vueuse/gesture": "^2.0.0",
|
||||
"@vueuse/math": "^11.3.0",
|
||||
"@vueuse/nuxt": "^11.3.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"drizzle-orm": "^0.35.3",
|
||||
"nuxt": "^3.14.159",
|
||||
"nuxt-security": "^2.0.0",
|
||||
"radix-vue": "^1.9.8",
|
||||
"hast": "^1.0.0",
|
||||
"lodash.capitalize": "^4.2.1",
|
||||
"mdast-util-find-and-replace": "^3.0.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nuxt": "^3.14.1592",
|
||||
"nuxt-security": "^2.1.5",
|
||||
"radix-vue": "^1.9.10",
|
||||
"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-router": "latest",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.12",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"bun-types": "^1.1.34",
|
||||
"drizzle-kit": "^0.26.2"
|
||||
"@types/bun": "^1.1.14",
|
||||
"@types/lodash.capitalize": "^4.2.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/unist": "^3.0.3",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"bun-types": "^1.1.38",
|
||||
"drizzle-kit": "^0.26.2",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"rehype-stringify": "^10.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,276 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Format bytes as human-readable text.
|
||||
*
|
||||
* @param bytes Number of bytes.
|
||||
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||
* binary (IEC), aka powers of 1024.
|
||||
* @param dp Number of decimal places to display.
|
||||
*
|
||||
* @return Formatted string.
|
||||
*/
|
||||
function textualFileSize(bytes: number, si: boolean = false, dp: number = 2) {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
const units = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
let u = -1;
|
||||
const r = 10**dp;
|
||||
|
||||
do {
|
||||
bytes /= thresh;
|
||||
++u;
|
||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||
|
||||
|
||||
return bytes.toFixed(dp) + ' ' + units[u];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { format, iconByType } from '~/shared/general.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({
|
||||
rights: ['admin'],
|
||||
})
|
||||
const job = ref<string>('');
|
||||
});
|
||||
|
||||
const toaster = useToast();
|
||||
const data = ref(), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), success = ref(false), err = ref(false), error = ref();
|
||||
async function fetch()
|
||||
{
|
||||
status.value = 'pending';
|
||||
data.value = null;
|
||||
error.value = null;
|
||||
err.value = false;
|
||||
success.value = false;
|
||||
|
||||
const { data: users } = useFetch('/api/admin/users', {
|
||||
transform: (users) => {
|
||||
//@ts-ignore
|
||||
users.forEach(e => e.permission = e.permission.map(p => p.permission));
|
||||
//@ts-ignore
|
||||
return users as User[];
|
||||
},
|
||||
});
|
||||
const { data: pages } = useFetch('/api/admin/pages');
|
||||
|
||||
const sorter = ref<((a: File, b: File) => number) | null>(null);
|
||||
const sortField = ref<keyof File | null>(null), sortOrder = ref<null | 'asc' | 'desc'>('asc');
|
||||
const sortedPage = ref([...pages.value ?? []]);
|
||||
|
||||
const permissionCopy = ref<string[]>([]);
|
||||
|
||||
watch([sortField, sortOrder, sorter], () => {
|
||||
sortedPage.value = (sorter.value === null ? ([...pages.value ?? []]) : sortedPage.value.sort(sorter.value))
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
function sort(field: keyof File, type: 'string' | 'number')
|
||||
{
|
||||
if(sortField.value === field)
|
||||
{
|
||||
if(sortOrder.value === 'asc')
|
||||
{
|
||||
sortOrder.value = 'desc';
|
||||
sorter.value = type === 'string' ? (a: File, b: File) => (b[field] as string).localeCompare(a[field] as string) : (a: File, b: File) => (b[field] as number) - (a[field] as number);
|
||||
}
|
||||
else
|
||||
{
|
||||
sortOrder.value = null;
|
||||
sortField.value = null;
|
||||
sorter.value = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sortField.value = field;
|
||||
sortOrder.value = 'asc';
|
||||
sorter.value = type === 'string' ? (a: File, b: File) => (a[field] as string).localeCompare(b[field] as string) : (a: File, b: File) => (a[field] as number) - (b[field] as number);
|
||||
}
|
||||
}
|
||||
async function editPermissions(user: User)
|
||||
{
|
||||
try
|
||||
{
|
||||
data.value = await $fetch(`/api/admin/jobs/${job.value}`, {
|
||||
await $fetch(`/api/admin/user/${user.id}/permissions`, {
|
||||
method: 'POST',
|
||||
body: permissionCopy.value,
|
||||
});
|
||||
user.permission = permissionCopy.value;
|
||||
toaster.add({
|
||||
duration: 10000, type: 'success', content: 'Permissions mises à jour.', timer: true,
|
||||
});
|
||||
status.value = 'success';
|
||||
error.value = null;
|
||||
err.value = false;
|
||||
success.value = true;
|
||||
|
||||
toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, });
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
status.value = 'error';
|
||||
error.value = e;
|
||||
err.value = true;
|
||||
success.value = false;
|
||||
toaster.add({
|
||||
duration: 10000, type: 'error', content: (e as any).message, timer: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
async function logout(user: User)
|
||||
{
|
||||
try
|
||||
{
|
||||
await $fetch(`/api/admin/user/${user.id}/logout`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
toaster.add({ duration: 10000, content: error.value, type: 'error', timer: true, });
|
||||
user.session.length = 0;
|
||||
|
||||
toaster.add({
|
||||
duration: 10000, type: 'success', content: 'L\'utilisateur vient d\'être déconnecté.', timer: true,
|
||||
});
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
toaster.add({
|
||||
duration: 10000, type: 'error', content: (e as any).message, timer: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head>
|
||||
<Title>Administration</Title>
|
||||
<Title>d[any] - Administration</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col justify-start">
|
||||
<ProseH2>Administration</ProseH2>
|
||||
<Select label="Job" v-model="job">
|
||||
<SelectItem label="Synchroniser" value="sync" />
|
||||
<SelectItem label="Nettoyer la base" value="clear" disabled />
|
||||
<SelectItem label="Reconstruire" value="rebuild" disabled />
|
||||
</Select>
|
||||
<Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'">
|
||||
<span>Executer</span>
|
||||
</Button>
|
||||
<div class="flex flex-1 flex-col p-4">
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
<ProseH2 class="text-center flex-1">Administration</ProseH2>
|
||||
<Button><NuxtLink :to="{ name: 'admin-jobs' }">Jobs</NuxtLink></Button>
|
||||
</div>
|
||||
<div class="flex flex-1 w-full justify-center items-stretch flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<Collapsible v-if=users :label="`Utilisateurs (${users.length})`">
|
||||
<div class="flex flex-1 mt-2">
|
||||
<table class="border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Utilisateur</th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Inscription</th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Dernière connexion</th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Mail</th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Sessions</th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Permissions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="font-normal">
|
||||
<tr v-for="user in users">
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 max-w-32 truncate"><NuxtLink :to="{ name: 'user-id', params: { id: user.id } }" class="hover:text-accent-purple font-bold" :title="user.username">{{ user.username }}</NuxtLink></td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center">{{ format(new Date(user.data.signin), 'dd/MM/yyyy') }}</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center">{{ format(new Date(user.data.lastTimestamp), 'dd/MM/yyyy HH:mm:ss') }}</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center"><Icon :class="{ 'text-light-red dark:text-dark-red': user.state === 0, 'text-light-green dark:text-dark-green': user.state !== 0 }" :icon="user.state === 0 ? `radix-icons:cross-2` : `radix-icons:check`" /></td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1">
|
||||
<DialogRoot>
|
||||
<DialogTrigger asChild><span class="text-accent-blue hover:text-accent-purple font-bold cursor-pointer">{{ user.session.length }}</span></DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<DialogContent
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||
<DialogTitle class="text-3xl font-light relative -top-2">Deconnecter l'utilisateur ?
|
||||
</DialogTitle>
|
||||
<div class="flex flex-1 justify-end gap-4">
|
||||
<DialogClose asChild><Button>Non</Button></DialogClose>
|
||||
<DialogClose asChild><Button @click="() => logout(user)" class="border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green hover:bg-light-greenBack dark:hover:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green dark:focus:shadow-dark-green">Oui</Button></DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1">
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger asChild><span class="text-accent-blue hover:text-accent-purple font-bold" @click="permissionCopy = [...user.permission]">{{ user.permission.length }}</span></AlertDialogTrigger>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<AlertDialogContent
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||
<AlertDialogTitle class="text-3xl font-light relative -top-2">Permissions de {{ user.username }}</AlertDialogTitle>
|
||||
<AlertDialogDescription><TagsInput v-model="permissionCopy" /></AlertDialogDescription>
|
||||
<div class="flex flex-1 justify-end gap-4">
|
||||
<AlertDialogCancel asChild><Button>Annuler</Button></AlertDialogCancel>
|
||||
<AlertDialogAction asChild><Button @click="() => editPermissions(user)" class="border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green hover:bg-light-greenBack dark:hover:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green dark:focus:shadow-dark-green">Modifier</Button></AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Collapsible v-if=pages :label="`Pages (${pages.length})`">
|
||||
<div class="flex flex-1 mt-2">
|
||||
<table class="border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Page</span><span @click="() => sort('title', 'string')"><Icon :icon="sortField === 'title' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Type</span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Propriétaire</span><span @click="() => sort('owner', 'number')"><Icon :icon="sortField === 'owner' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Status</span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Taille</span><span @click="() => sort('size', 'number')"><Icon :icon="sortField === 'size' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Visites</span><span @click="() => sort('visit', 'number')"><Icon :icon="sortField === 'visit' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Actions</span></div></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="font-normal">
|
||||
<DialogRoot>
|
||||
<tr v-for="page in sortedPage" :id="page.path">
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 max-w-48 truncate"><NuxtLink :to="{ name: 'explore-path', params: { path: page.path } }" class="hover:text-accent-purple font-bold" :title="page.title">{{ page.title }}</NuxtLink></td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1"><Icon :icon="iconByType[page.type]" /></td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center max-w-32 truncate"><span :title=" users?.find(e => e.id === page.owner)?.username ?? 'Inconnu'">{{ users?.find(e => e.id === page.owner)?.username ?? "Inconnu" }}</span></td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 ">
|
||||
<div class="flex gap-2 justify-center">
|
||||
<span>
|
||||
<Icon v-if="page.private" icon="radix-icons:lock-closed" />
|
||||
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:lock-open-2" />
|
||||
</span>
|
||||
<span>
|
||||
<Icon v-if="page.navigable" icon="radix-icons:eye-open" />
|
||||
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:eye-none" />
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center">{{ textualFileSize(page.size) }}</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center">{{ page.visit }}</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center"><div class="flex justify-center items-center"><NuxtLink :to="{ name: 'explore-edit', hash: '#' + page.path }"><Icon icon="radix-icons:pencil-1" /></NuxtLink></div></td>
|
||||
</tr>
|
||||
</DialogRoot>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
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,22 +0,0 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>Editeur</Title>
|
||||
</Head>
|
||||
<Editor v-model="model" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
rights: ['admin', 'editor'],
|
||||
})
|
||||
const model = defineModel<string>({
|
||||
default: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam quis orci et est malesuada vulputate. Aenean sagittis congue eros, non feugiat metus bibendum consectetur. Duis volutpat leo nisi, in maximus nulla rhoncus ac. Sed scelerisque ipsum et volutpat dignissim. Integer massa nibh, imperdiet quis condimentum vitae, imperdiet quis quam. Cras pretium ex eget hendrerit porttitor. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque rutrum scelerisque quam, sit amet malesuada mi convallis aliquam. Curabitur eget dolor in diam scelerisque tincidunt at et sapien. Nulla vel nisl finibus odio porttitor sagittis ac ut sem. Aenean orci enim, fringilla eu porta eget, egestas vel libero. Aenean ac efficitur nunc, id finibus nibh. Suspendisse potenti. Quisque vel vestibulum ante. Morbi mi nulla, gravida ac malesuada at, hendrerit nec nibh.
|
||||
|
||||
Fusce sodales convallis velit, ac tempor sem auctor sed.Aenean commodo sodales lorem eu mollis.Suspendisse lectus diam, bibendum quis maximus id, euismod placerat velit.Vestibulum hendrerit justo vel ultricies molestie.Donec rhoncus, ante at facilisis fermentum, diam diam hendrerit nunc, et dapibus lacus leo in massa.Duis iaculis sem sed molestie posuere.Morbi a erat hendrerit, volutpat libero non, elementum dui.
|
||||
|
||||
Cras imperdiet velit cursus, fringilla tellus eu, lacinia neque.Sed id est suscipit quam gravida vestibulum ut sed tortor.Aliquam erat volutpat.Praesent non orci ac quam consequat tempor.Nulla facilisi.Proin at vulputate lectus.Nunc at tellus at diam faucibus eleifend et et diam.Duis pellentesque lobortis lectus id egestas.Sed quis lacinia sapien.Quisque porta tincidunt pulvinar.Aliquam hendrerit hendrerit quam, sed pulvinar turpis dictum nec.
|
||||
|
||||
Donec bibendum, orci nec tempus fermentum, diam tellus pretium elit, vel porttitor ligula lectus a augue.Aliquam tristique, mi eu mollis sodales, enim lorem hendrerit est, id semper dui tellus id felis.Duis finibus lacus nunc, vitae tincidunt metus sagittis at.Curabitur euismod neque sed malesuada consectetur.Aliquam eget efficitur urna.Sed neque sem, interdum in turpis vitae, efficitur aliquam neque.Integer consectetur consequat diam, sed suscipit arcu maximus ac.Nunc imperdiet leo condimentum tellus luctus porta.Aenean et lorem sit amet eros rutrum fermentum.
|
||||
|
||||
Nam placerat leo sed nulla imperdiet dapibus.Etiam vitae tortor efficitur, interdum ipsum non, tincidunt ante.Quisque et placerat nisi, eu bibendum neque.Nulla facilisi.Pellentesque accumsan lacus arcu, vitae iaculis elit sollicitudin quis.Sed et iaculis neque.In quis nunc laoreet turpis fermentum sodales.Etiam eget sodales lorem.Nunc id risus ac purus mollis auctor.Integer imperdiet placerat massa eu efficitur.` });
|
||||
</script>
|
||||
@@ -1,35 +1,45 @@
|
||||
<template>
|
||||
<div v-if="status === 'pending'" class="flex">
|
||||
<div v-if="overviewStatus === 'pending'" class="flex">
|
||||
<Head>
|
||||
<Title>d[any] - Chargement</Title>
|
||||
</Head>
|
||||
<Loading />
|
||||
</div>
|
||||
<div class="flex flex-1 justify-start items-start" v-else-if="page">
|
||||
<div class="flex flex-1 justify-start items-start" v-else-if="overview">
|
||||
<Head>
|
||||
<Title>d[any] - {{ page.title }}</Title>
|
||||
<Title>d[any] - {{ overview.title }}</Title>
|
||||
</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 flex-row justify-between items-center">
|
||||
<ProseH1>{{ page.title }}</ProseH1>
|
||||
<NuxtLink :href="{ name: 'explore-edit-path', params: { path: path } }"><Button v-if="isOwner">Modifier</Button></NuxtLink>
|
||||
<ProseH1>{{ overview.title }}</ProseH1>
|
||||
<div class="flex gap-4">
|
||||
<NuxtLink :href="{ name: 'explore-edit', hash: '#' + overview.path }" v-if="isOwner"><Button>Modifier</Button></NuxtLink>
|
||||
</div>
|
||||
<Markdown :content="page.content" />
|
||||
</div>
|
||||
<Markdown v-if="content" :content="content.content" />
|
||||
<Loading v-else />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page.type === 'canvas'">
|
||||
<Canvas :canvas="JSON.parse(page.content)" />
|
||||
<template v-else-if="overview.type === 'canvas'">
|
||||
<Canvas v-if="content" :canvas="JSON.parse(content.content)" />
|
||||
<Loading v-else />
|
||||
</template>
|
||||
<template v-else>
|
||||
<ProseH2 class="flex-1 text-center">Impossible d'afficher le contenu demandé</ProseH2>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="status === 'error'">
|
||||
<div v-else-if="overviewStatus === 'error'">
|
||||
<Head>
|
||||
<Title>d[any] - Erreur</Title>
|
||||
</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 v-else>
|
||||
<Head>
|
||||
@@ -42,8 +52,10 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRouter().currentRoute;
|
||||
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
|
||||
const { loggedIn, user } = useUserSession();
|
||||
|
||||
const { data: page, status, error } = await useFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [route, path], });
|
||||
const isOwner = computed(() => user.value?.id === page.value?.owner);
|
||||
const { user } = useUserSession();
|
||||
|
||||
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>
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<div v-if="page" class="xl:p-12 lg:p-8 py-4 flex flex-1 flex-col items-start justify-start max-h-full">
|
||||
<Head>
|
||||
<Title>Modification de {{ page.title }}</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col xl:flex-row xl:justify-between justify-center items-center w-full px-4 pb-4 border-b border-light-35 dark:border-dark-35 bg-light-0 dark:bg-dark-0">
|
||||
<input type="text" v-model="page.title" placeholder="Titre" class="flex-1 mx-4 h-16 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 appearance-none outline-none px-3 py-1 text-5xl font-thin bg-transparent" />
|
||||
<div class="flex gap-4 self-end xl:self-auto">
|
||||
<Tooltip message="Consultable uniquement par le propriétaire" side="bottom"><Switch label="Privé" v-model="page.private" /></Tooltip>
|
||||
<Tooltip message="Afficher dans le menu de navigation" side="bottom"><Switch label="Navigable" v-model="page.navigable" /></Tooltip>
|
||||
<Button @click="() => save()" :loading="saveStatus === 'pending'" class="border-light-blue dark:border-dark-blue hover:border-light-blue dark:hover:border-dark-blue focus:shadow-light-blue dark:focus:shadow-dark-blue">Enregistrer</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-4 flex-1 w-full max-h-full flex">
|
||||
<template v-if="page.type === 'markdown'">
|
||||
<SplitterGroup direction="horizontal" class="flex-1 w-full flex">
|
||||
<SplitterPanel asChild>
|
||||
<textarea v-model="page.content" class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto"></textarea>
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" />
|
||||
<SplitterPanel asChild>
|
||||
<div class="flex-1 max-h-full !overflow-y-auto px-8"><Markdown :content="debounced" :proses="{ 'a': FakeA }" /></div>
|
||||
</SplitterPanel>
|
||||
</SplitterGroup>
|
||||
</template>
|
||||
<template v-else-if="page.type === 'canvas'">
|
||||
<span class="flex-1 items-center"><ProseH1>Editeur de graphe en cours de développement</ProseH1></span>
|
||||
</template>
|
||||
<template v-else-if="page.type === 'file'">
|
||||
<span>Modifier le contenu :</span><input type="file" @change="(e) => console.log(e)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="status === 'pending'" class="flex">
|
||||
<Head>
|
||||
<Title>Chargement</Title>
|
||||
</Head>
|
||||
<Loading />
|
||||
</div>
|
||||
<div v-else-if="status === 'error'">{{ error?.message }}</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FakeA from '~/components/prose/FakeA.vue';
|
||||
|
||||
const route = useRouter().currentRoute;
|
||||
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
|
||||
const { user, loggedIn } = useUserSession();
|
||||
|
||||
const toaster = useToast();
|
||||
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
|
||||
|
||||
const { data: page, status, error } = await useLazyFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [ route, path ]});
|
||||
const content = computed(() => page.value?.content);
|
||||
const debounced = useDebounce(content, 250);
|
||||
|
||||
if(!loggedIn || (page.value && page.value.owner !== user.value?.id))
|
||||
{
|
||||
useRouter().replace({ name: 'explore-path', params: { path: path.value } });
|
||||
}
|
||||
|
||||
async function save(): Promise<void>
|
||||
{
|
||||
saveStatus.value = 'pending';
|
||||
try {
|
||||
await $fetch(`/api/file`, {
|
||||
method: 'post',
|
||||
body: page.value,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
saveStatus.value = 'success';
|
||||
|
||||
toaster.clear('error');
|
||||
toaster.add({
|
||||
type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000
|
||||
});
|
||||
|
||||
useRouter().push({ name: 'explore-path', params: { path: path.value } });
|
||||
} catch(e: any) {
|
||||
toaster.add({
|
||||
type: 'error', content: e.message, timer: true, duration: 10000
|
||||
})
|
||||
saveStatus.value = 'error';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
543
pages/explore/edit/index.vue
Normal file
543
pages/explore/edit/index.vue
Normal file
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Modification</Title>
|
||||
</Head>
|
||||
<ClientOnly>
|
||||
<CollapsibleRoot asChild class="flex flex-1 flex-col xl:-mx-12 xl:-my-8 lg:-mx-8 lg:-my-6 -mx-6 -my-3 overflow-hidden" v-model="open">
|
||||
<div>
|
||||
<div class="z-50 md:hidden flex w-full items-center justify-between h-12 border-b border-light-35 dark:border-dark-35">
|
||||
<div class="flex items-center px-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button icon class="ms-2 !bg-transparent group">
|
||||
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" />
|
||||
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<div class="flex items-center px-2">
|
||||
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-row relative overflow-hidden">
|
||||
<CollapsibleContent asChild forceMount>
|
||||
<div class=" overflow-hidden bg-light-0 dark:bg-dark-0 z-40 xl:w-96 md:w-[15em] max-h-full w-full border-r border-light-30 dark:border-dark-30 flex flex-col justify-between max-md:absolute max-md:-top-0 max-md:-bottom-0 md:left-0 max-md:data-[state=closed]:-left-full max-md:transition-[left] max-md:z-40 max-md:data-[state=open]:left-0">
|
||||
<div class="flex flex-col gap-4 xl:px-6 px-3 py-4">
|
||||
<div class="flex justify-between items-center max-md:hidden">
|
||||
<div class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-md:ps-6" aria-label="Accueil">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
||||
<Avatar src="/logo.light.svg" class="block dark:hidden" />
|
||||
</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col max-w-full max-h-full overflow-hidden py-3" v-if="navigation">
|
||||
<div class="flex flex-row justify-between items-center mb-4 px-6">
|
||||
<div class="flex flex-1 flex-row justify-start items-center gap-4">
|
||||
<Tooltip side="top" message="Annuler (Ctrl+Shift+W)" ><Button icon @click="router.go(-1)"><Icon class="w-5 h-5" icon="radix-icons:arrow-left" /></Button></Tooltip>
|
||||
<Tooltip side="top" message="Enregistrer (Ctrl+S)" ><Button icon :loading="saveStatus === 'pending'" @click="save(true)"><Icon class="w-5 h-5" icon="radix-icons:check" /></Button></Tooltip>
|
||||
<span v-if="edited" class="text-sm text-light-60 dark:text-dark-60 italic">Modifications non enregistrées</span>
|
||||
</div>
|
||||
<div class="flex flex-row justify-end items-center gap-4">
|
||||
<AlertDialogRoot v-if="selected">
|
||||
<Tooltip side="top" message="Supprimer"><AlertDialogTrigger as="span"><Button icon class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red" ><Icon class="w-5 h-5" icon="radix-icons:trash" /></Button></AlertDialogTrigger></Tooltip>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<AlertDialogContent class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100 flex md:flex-row flex-col gap-4 items-center">
|
||||
<AlertDialogTitle class="text-xl font-semibold">Supprimer <span>{{ selected.title }}</span><span v-if="selected.children"> et tous ces enfants</span> ?</AlertDialogTitle>
|
||||
<div class="flex flex-1 flex-row gap-4 justify-end">
|
||||
<AlertDialogAction asChild @click="navigation = tree.remove(navigation, getPath(selected))"><Button class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
|
||||
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
<Tooltip side="top" message="Nouveau">
|
||||
<DropdownMenu align="center" side="bottom" :options="[{
|
||||
type: 'item',
|
||||
label: 'Markdown',
|
||||
kbd: 'Ctrl+N',
|
||||
icon: 'radix-icons:file',
|
||||
select: () => add('markdown'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Dossier',
|
||||
kbd: 'Ctrl+Shift+N',
|
||||
icon: 'lucide:folder',
|
||||
select: () => add('folder'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Canvas',
|
||||
icon: 'ph:graph-light',
|
||||
select: () => add('canvas'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Fichier',
|
||||
icon: 'radix-icons:file-text',
|
||||
select: () => add('file'),
|
||||
}]">
|
||||
<Button icon><Icon class="w-5 h-5" icon="radix-icons:plus" /></Button>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<DraggableTree class="ps-4 pe-2 xl:text-base text-sm"
|
||||
:items="navigation ?? undefined" :get-key="(item: Partial<ProjectExtendedItem>) => item.path !== undefined ? getPath(item as ProjectExtendedItem) : ''" @updateTree="drop"
|
||||
v-model="selected" :defaultExpanded="defaultExpanded" >
|
||||
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, item }">
|
||||
<div class="flex flex-1 items-center px-2 max-w-full pe-4" :class="{ 'opacity-50': isDragging }" :style="{ 'padding-left': `${item.level - 0.5}em` }">
|
||||
<span class="py-2 px-2" @click="handleToggle" v-if="item.hasChildren" >
|
||||
<Icon :icon="isExpanded ? 'lucide:folder-open' : 'lucide:folder'"/>
|
||||
</span>
|
||||
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="group-[:hover]:text-accent-purple mx-2" @click="handleSelect" />
|
||||
<div class="pl-3 py-1 flex-1 truncate" :title="item.value.title" @click="handleSelect" :class="{ 'font-semibold': item.hasChildren }">
|
||||
{{ item.value.title }}
|
||||
</div>
|
||||
<div 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>
|
||||
</CollapsibleContent>
|
||||
<div class="flex flex-1 flex-row max-h-full overflow-hidden">
|
||||
<div v-if="selected" class="xl:px-12 xl:py-8 lg:px-8 lg:py-6 px-6 py-3 flex flex-1 flex-col items-start justify-start max-h-full relative">
|
||||
<Head>
|
||||
<Title>d[any] - Modification de {{ selected.title }}</Title>
|
||||
</Head>
|
||||
<div>
|
||||
<input type="text" v-model="selected.title" placeholder="Titre" class="inline md:h-16 h-12 w-full caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 appearance-none outline-none py-1 md:text-5xl text-4xl font-thin bg-transparent" />
|
||||
<div class="flex flex-col justify-start items-start">
|
||||
<Switch label="Chemin personnalisé" v-model="selected.customPath" />
|
||||
<span>
|
||||
<pre v-if="selected.customPath" class="flex md:items-center md:text-base md:text-nowrap text-wrap md:flex-nowrap flex-wrap text-sm">/{{ selected.parent !== '' ? selected.parent + '/' : '' }}<TextInput v-model="selected.name" @input="(e) => {
|
||||
if(selected && selected.customPath)
|
||||
{
|
||||
selected.name = parsePath(selected.name);
|
||||
rebuildPath(selected.children, getPath(selected));
|
||||
}
|
||||
}" class="mx-0"/></pre>
|
||||
<pre v-else class="md:text-base text-smmd:text-nowrap text-wrap ">/{{ getPath(selected) }}</pre>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-4 flex-1 w-full max-h-full flex overflow-hidden">
|
||||
<template v-if="selected.type === 'markdown'">
|
||||
<div v-if="contentStatus === 'pending'" class="flex flex-1 justify-center items-center">
|
||||
<Loading />
|
||||
</div>
|
||||
<span v-else-if="contentError">{{ contentError }}</span>
|
||||
<SplitterGroup direction="horizontal" class="flex-1 w-full flex" v-else-if="selected.content !== undefined">
|
||||
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }" :defaultSize="50">
|
||||
<Editor v-model="selected.content" placeholder="Commencer votre aventure ..." class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto" :class="{ 'hidden': isCollapsed }" />
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" />
|
||||
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }">
|
||||
<div class="flex-1 max-h-full !overflow-y-auto px-8" :class="{ 'hidden': isCollapsed }"><Markdown :content="debounced" :proses="{ 'a': FakeA }" /></div>
|
||||
</SplitterPanel>
|
||||
</SplitterGroup>
|
||||
</template>
|
||||
<template v-else-if="selected.type === 'canvas'">
|
||||
<span class="flex flex-1 justify-center items-center"><ProseH3>Editeur de graphe en cours de développement</ProseH3></span>
|
||||
</template>
|
||||
<template v-else-if="selected.type === 'file'">
|
||||
<span>Modifier le contenu :</span><input type="file" @change="(e) => console.log(e)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleRoot>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item';
|
||||
import { parsePath } from '#shared/general.utils';
|
||||
import type { ProjectItem } from '~/schemas/project';
|
||||
import type { FileType } from '~/schemas/file';
|
||||
import { iconByType } from '#shared/general.utils';
|
||||
import FakeA from '~/components/prose/FakeA.vue';
|
||||
|
||||
interface ProjectExtendedItem extends ProjectItem
|
||||
{
|
||||
customPath: boolean
|
||||
content?: string
|
||||
children?: ProjectExtendedItem[]
|
||||
}
|
||||
interface ProjectExtended
|
||||
{
|
||||
items: ProjectExtendedItem[]
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
rights: ['admin', 'editor'],
|
||||
layout: 'null',
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const open = ref(true);
|
||||
|
||||
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;
|
||||
edited.value = true;
|
||||
|
||||
if(proj && value)
|
||||
proj.items = value;
|
||||
|
||||
project.value = proj;
|
||||
}
|
||||
});
|
||||
const selected = ref<ProjectExtendedItem>(), edited = ref(false);
|
||||
const contentStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), contentError = ref<string>();
|
||||
|
||||
watch(selected, async (value, old) => {
|
||||
if(selected.value)
|
||||
{
|
||||
if(!selected.value.content && selected.value.path)
|
||||
{
|
||||
contentStatus.value = 'pending';
|
||||
try
|
||||
{
|
||||
const storedEdit = sessionStorage.getItem(`editing:${encodeURIComponent(selected.value.path)}`);
|
||||
|
||||
if(storedEdit)
|
||||
{
|
||||
selected.value.content = storedEdit;
|
||||
contentStatus.value = 'success';
|
||||
}
|
||||
else
|
||||
{
|
||||
selected.value.content = (await $fetch(`/api/file/content/${encodeURIComponent(selected.value.path)}`, { query: { type: 'editing'} }))?.content;
|
||||
contentStatus.value = 'success';
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
debounced.value = selected.value.content ?? '';
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
contentError.value = (e as Error).message;
|
||||
contentStatus.value = 'error';
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//@ts-ignore
|
||||
debounced.value = selected.value.content ?? '';
|
||||
}
|
||||
router.replace({ hash: '#' + encodeURIComponent(selected.value.path || getPath(selected.value)) });
|
||||
}
|
||||
else
|
||||
{
|
||||
router.replace({ hash: '' });
|
||||
}
|
||||
})
|
||||
const content = computed(() => selected.value?.content ?? '');
|
||||
const debounced = useDebounce(content, 250, { maxWait: 500 });
|
||||
|
||||
watch(debounced, () => {
|
||||
if(selected.value && debounced.value)
|
||||
sessionStorage.setItem(`editing:${encodeURIComponent(selected.value.path)}`, debounced.value);
|
||||
});
|
||||
useShortcuts({
|
||||
meta_s: { usingInput: true, handler: () => save(false) },
|
||||
meta_n: { usingInput: true, handler: () => add('markdown') },
|
||||
meta_shift_n: { usingInput: true, handler: () => add('folder') },
|
||||
meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore-path', params: { path: 'index' } }) }
|
||||
})
|
||||
|
||||
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, content: type === 'markdown' ? '' : undefined };
|
||||
|
||||
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';
|
||||
edited.value = false;
|
||||
sessionStorage.clear();
|
||||
|
||||
toaster.clear('error');
|
||||
toaster.add({
|
||||
type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000
|
||||
});
|
||||
|
||||
if(redirect) router.go(-1);
|
||||
} 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('/');
|
||||
}
|
||||
|
||||
const defaultExpanded = computed(() => {
|
||||
if(router.currentRoute.value.hash)
|
||||
{
|
||||
const split = router.currentRoute.value.hash.substring(1).split('/');
|
||||
split.forEach((e, i) => { if(i !== 0) split[i] = split[i - 1] + '/' + e });
|
||||
return split;
|
||||
}
|
||||
})
|
||||
watch(router.currentRoute, (value) => {
|
||||
if(value && value.hash && navigation.value)
|
||||
selected.value = tree.find(navigation.value, decodeURIComponent(value.hash.substring(1)));
|
||||
else
|
||||
selected.value = undefined;
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
@@ -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>
|
||||
<Head>
|
||||
<Title>d[any] - Accueil</Title>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Mentions légales</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col max-w-[1200px] p-16">
|
||||
<ProseH3>Mentions Légales</ProseH3>
|
||||
<ProseH4>Collecte et Traitement des Données Personnelles</ProseH4>
|
||||
|
||||
53
pages/roadmap.vue
Normal file
53
pages/roadmap.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Roadmap</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col justify-start p-6">
|
||||
<ProseH2>Roadmap</ProseH2>
|
||||
<div class="grid grid-cols-4 gap-x-2 gap-y-4">
|
||||
<div v-if="loggedIn && user && hasPermissions(user.permissions, ['admin'])" class="flex flex-col gap-2 justify-start">
|
||||
<ProseH3>Administration</ProseH3>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Dashboard de statistiques</span></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Editeur de permissions</span><ProseTag>prioritaire</ProseTag></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Synchro project <-> GIT</span><ProseTag>prioritaire</ProseTag></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Versionning automatisé, releases et newsletter</span></Label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 justify-start">
|
||||
<ProseH3>Editeur</ProseH3>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition de page</span></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition riche de page</span></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Edition live de page</span></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Raccourcis d'edition</span></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Affichage alternatif par page</span></Label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 justify-start">
|
||||
<ProseH3>Projet</ProseH3>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition du projet</span></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Déplacement des fichiers</span></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Configuration de droit du projet</span><ProseTag>prioritaire</ProseTag></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Theme par projet</span></Label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 justify-start">
|
||||
<ProseH3>Nouvelles features</ProseH3>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Historique des modifs</span><ProseTag>prioritaire</ProseTag></Label><!-- Objet release: key hash, timestamp, version, name, description?. Objet edit: key hash, key property, value, timestamp -->
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Commentaire par page</span></Label><!-- Object comment: key path, key comment_id, position, content, owner, following? -->
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Type de fichier: Timeline</span></Label><!-- Propriétés: array of (from, (to || ponctual), ((title, content) || dedicated page)) -->
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Type de fichier: Whiteboard</span></Label><!-- Tableau de données SVG -->
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 justify-start">
|
||||
<ProseH3>Utilisateur</ProseH3>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Validation du compte par mail<ProseTag>prioritaire</ProseTag></span></Label>
|
||||
<!-- <Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Modification de profil</span></Label> -->
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Image de profil</span></Label>
|
||||
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Préférence d'email</span></Label><!-- New features, newsletter et surveys -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import { hasPermissions } from '~/shared/auth.util';
|
||||
|
||||
const { loggedIn, user } = useUserSession();
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>Connexion</Title>
|
||||
<Title>d[any] - Connexion</Title>
|
||||
</Head>
|
||||
<div class="flex flex-1 flex-col justify-center items-center">
|
||||
<div class="flex gap-8 items-center">
|
||||
|
||||
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">
|
||||
import { hasPermissions } from "#shared/auth.util";
|
||||
|
||||
definePageMeta({
|
||||
guestsGoesTo: '/user/login',
|
||||
})
|
||||
let { user, clear } = useUserSession();
|
||||
const { user, clear } = useUserSession();
|
||||
const toaster = useToast();
|
||||
const loading = ref<boolean>(false);
|
||||
|
||||
async function revalidateUser()
|
||||
{
|
||||
loading.value = true;
|
||||
await $fetch(`/api/users/${user.value?.id}/revalidate`, {
|
||||
method: 'get'
|
||||
});
|
||||
loading.value = false;
|
||||
toaster.add({ closeable: false, duration: 10000, timer: true, content: 'Un mail vous a été envoyé.', type: 'info' });
|
||||
}
|
||||
async function deleteUser()
|
||||
{
|
||||
loading.value = true;
|
||||
await $fetch(`/api/users/${user.value?.id}`, {
|
||||
method: 'delete'
|
||||
});
|
||||
loading.value = false;
|
||||
clear();
|
||||
}
|
||||
</script>
|
||||
@@ -16,7 +31,7 @@ async function deleteUser()
|
||||
<template>
|
||||
|
||||
<Head>
|
||||
<Title>Mon profil</Title>
|
||||
<Title>d[any] - Mon profil</Title>
|
||||
</Head>
|
||||
<div class="grid lg:grid-cols-4 grid-col-2 w-full items-start py-8 gap-6 content-start" v-if="user">
|
||||
<div class="flex flex-col gap-4 col-span-4 lg:col-span-3 border border-light-35 dark:border-dark-35 p-4">
|
||||
@@ -34,14 +49,14 @@ async function deleteUser()
|
||||
<template v-slot:content><span>Tant que votre adresse mail n'as pas été validée, vous n'avez que
|
||||
des droits de lecture.</span></template>
|
||||
</HoverCard>
|
||||
<Tooltip message="En cours de développement"><Button class="ms-4" disabled>Renvoyez un mail</Button></Tooltip>
|
||||
<Button class="ms-4" @click="revalidateUser" :loading="loading">Renvoyez un mail</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col self-center flex-1 gap-4">
|
||||
<Button @click="async () => await clear()">Se deconnecter</Button>
|
||||
<Button @click="clear">Se deconnecter</Button>
|
||||
<Button disabled><Tooltip message="En cours de développement">Modifier mon profil</Tooltip></Button>
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger asChild><Button
|
||||
<AlertDialogTrigger asChild><Button :loading="loading"
|
||||
class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer
|
||||
mon compte</Button></AlertDialogTrigger>
|
||||
<AlertDialogPortal>
|
||||
@@ -64,19 +79,5 @@ async function deleteUser()
|
||||
</AlertDialogRoot>
|
||||
<NuxtLink v-if="hasPermissions(user.permissions, ['admin'])" :href="{ name: 'admin' }" class="flex" no-prefetch><Button class="flex-1">Administration</Button></NuxtLink>
|
||||
</div>
|
||||
<div class="flex" v-if="user.permissions">
|
||||
<ProseTable class="!m-0">
|
||||
<ProseThead>
|
||||
<ProseTr>
|
||||
<ProseTh>Permission</ProseTh>
|
||||
</ProseTr>
|
||||
</ProseThead>
|
||||
<ProseTbody>
|
||||
<ProseTr v-for="permission in user.permissions">
|
||||
<ProseTd>{{ permission }}</ProseTd>
|
||||
</ProseTr>
|
||||
</ProseTbody>
|
||||
</ProseTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>Inscription</Title>
|
||||
<Title>d[any] - Inscription</Title>
|
||||
</Head>
|
||||
<div class="flex flex-1 flex-col justify-center items-center">
|
||||
<div class="flex gap-8 items-center">
|
||||
@@ -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="email" label="Email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/>
|
||||
<TextInput type="password" label="Mot de passe" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/>
|
||||
<div class="flex flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||
<span class="">Votre mot de passe doit respecter les critères de sécurité suivants
|
||||
:</span>
|
||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedLength}">Entre 8 et 128
|
||||
caractères</span>
|
||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedLowerUpper}">Au moins
|
||||
une minuscule et une majuscule</span>
|
||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedDigit}">Au moins un
|
||||
chiffre</span>
|
||||
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}">Au moins un
|
||||
caractère spécial parmi la liste suivante:
|
||||
<pre class="text-wrap">! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ ] ^ _ ` { | } ~</pre>
|
||||
</span>
|
||||
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||
<span class="col-span-2">Prérequis de sécurité</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLower}"><Icon v-show="!checkedLower" icon="radix-icons:cross-2" />Une minuscule</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedUpper}"><Icon v-show="!checkedUpper" icon="radix-icons:cross-2" />Une majuscule</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedDigit}"><Icon v-show="!checkedDigit" icon="radix-icons:cross-2" />Un chiffre</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
|
||||
</div>
|
||||
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
|
||||
<Button class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
|
||||
@@ -52,7 +46,8 @@ const { add: addToast, clear: clearToasts } = useToast();
|
||||
const confirmPassword = ref("");
|
||||
|
||||
const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128);
|
||||
const checkedLowerUpper = computed(() => state.password.toLowerCase() !== state.password && state.password.toUpperCase() !== state.password);
|
||||
const checkedLower = computed(() => state.password.toUpperCase() !== state.password);
|
||||
const checkedUpper = computed(() => state.password.toLowerCase() !== state.password);
|
||||
const checkedDigit = computed(() => /[0-9]/.test(state.password));
|
||||
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));
|
||||
|
||||
|
||||
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";
|
||||
|
||||
export const fileType = z.enum(['folder', 'file', 'markdown', 'canvas']);
|
||||
export const schema = z.object({
|
||||
path: z.string(),
|
||||
owner: z.number(),
|
||||
owner: z.number().finite(),
|
||||
title: z.string(),
|
||||
type: z.enum(['folder', 'file', 'markdown', 'canvas']),
|
||||
type: fileType,
|
||||
content: z.string(),
|
||||
navigable: z.boolean(),
|
||||
private: z.boolean(),
|
||||
order: z.number().finite(),
|
||||
});
|
||||
|
||||
export type FileType = z.infer<typeof fileType>;
|
||||
export type File = z.infer<typeof schema>;
|
||||
16
schemas/navigation.ts
Normal file
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>;
|
||||
25
schemas/project.ts
Normal file
25
schemas/project.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
import { fileType } from "./file";
|
||||
|
||||
const baseItem = z.object({
|
||||
path: z.string(),
|
||||
parent: z.string(),
|
||||
name: z.string(),
|
||||
title: z.string(),
|
||||
type: fileType,
|
||||
navigable: z.boolean(),
|
||||
private: z.boolean(),
|
||||
order: z.number().finite(),
|
||||
content: z.string().optional(),
|
||||
});
|
||||
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({
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
13
server/api/__sitemap__/urls.ts
Normal file
13
server/api/__sitemap__/urls.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { SitemapUrlInput } from '#sitemap/types'
|
||||
import { explorerContentTable } from '~/db/schema';
|
||||
import useDatabase from '~/composables/useDatabase';
|
||||
|
||||
export default defineSitemapEventHandler(() => {
|
||||
const db = useDatabase();
|
||||
const pages = db.select({ path: explorerContentTable.path, lastMod: explorerContentTable.timestamp, navigable: explorerContentTable.navigable, private: explorerContentTable.private, type: explorerContentTable.type }).from(explorerContentTable).all();
|
||||
|
||||
return pages.filter(e => e.type !== 'folder' && e.navigable && !e.private && e.path.split('/').map((_, i, a) => a.slice(0, i).join('/')).every(p => !pages.find(_p => _p.path === p)?.private)).map(e => ({
|
||||
loc: `/explore/${encodeURIComponent(e.path)}`,
|
||||
lastmod: e.lastMod,
|
||||
})) satisfies SitemapUrlInput[];
|
||||
})
|
||||
@@ -1,3 +1,13 @@
|
||||
import { hasPermissions } from "#shared/auth.util";
|
||||
|
||||
declare module 'nitropack'
|
||||
{
|
||||
interface TaskPayload
|
||||
{
|
||||
type: string
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (e) => {
|
||||
const session = await getUserSession(e);
|
||||
|
||||
@@ -7,6 +17,7 @@ export default defineEventHandler(async (e) => {
|
||||
return;
|
||||
}
|
||||
const id = getRouterParam(e, 'id');
|
||||
const payload: Record<string, any> = await readBody(e);
|
||||
|
||||
if(!id)
|
||||
{
|
||||
@@ -14,12 +25,23 @@ export default defineEventHandler(async (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runTask(id);
|
||||
payload.type = id;
|
||||
payload.data = JSON.parse(payload.data);
|
||||
|
||||
const result = await runTask(id, {
|
||||
payload: payload
|
||||
});
|
||||
|
||||
if(!result.result)
|
||||
{
|
||||
setResponseStatus(e, 500);
|
||||
throw result.error ?? new Error('Erreur inconnue');
|
||||
|
||||
if(result.error && (result.error as Error).message)
|
||||
throw result.error;
|
||||
else if(result.error)
|
||||
throw new Error(result.error);
|
||||
else
|
||||
throw new Error('Erreur inconnue');
|
||||
}
|
||||
return
|
||||
return;
|
||||
});
|
||||
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');
|
||||
})
|
||||
40
server/api/admin/user/[id]/logout.post.ts
Normal file
40
server/api/admin/user/[id]/logout.post.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { hasPermissions } from "~/shared/auth.util";
|
||||
import useDatabase from '~/composables/useDatabase';
|
||||
import { and, eq, notInArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { userSessionsTable } from "~/db/schema";
|
||||
|
||||
const schema = z.array(z.string());
|
||||
|
||||
export default defineEventHandler(async (e) => {
|
||||
const session = await getUserSession(e);
|
||||
|
||||
if(!session || !session.user || !hasPermissions(session.user.permissions, ['admin']))
|
||||
{
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
const param = getRouterParam(e, 'id');
|
||||
|
||||
if(!param)
|
||||
{
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: 'Forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(param, 10);
|
||||
|
||||
const db = useDatabase();
|
||||
db.delete(userSessionsTable).where(eq(userSessionsTable.user_id, id)).run();
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
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 { ZodError } from 'zod';
|
||||
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';
|
||||
|
||||
interface SuccessHandler
|
||||
@@ -93,6 +93,8 @@ export default defineEventHandler(async (e): Promise<Return> => {
|
||||
}
|
||||
}) as UserSessionRequired);
|
||||
|
||||
db.update(usersDataTable).set({ logCount: user.data.logCount + 1 }).where(eq(usersDataTable.id, user.id)).run();
|
||||
|
||||
setResponseStatus(e, 201);
|
||||
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);
|
||||
|
||||
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);
|
||||
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 { explorerContentTable } from '~/db/schema';
|
||||
import { schema } from '~/schemas/file';
|
||||
import { parsePath } from '~/shared/general.utils';
|
||||
|
||||
export default defineEventHandler(async (e) => {
|
||||
const body = await readValidatedBody(e, schema.safeParse);
|
||||
@@ -10,10 +11,11 @@ export default defineEventHandler(async (e) => {
|
||||
throw body.error;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(body.data.content, 'utf-8');
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -23,3 +25,13 @@ export default defineEventHandler(async (e) => {
|
||||
setResponseStatus(e, 404);
|
||||
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;
|
||||
});
|
||||
}
|
||||
74
server/api/file/content/[path].get.ts
Normal file
74
server/api/file/content/[path].get.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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 && session.user.id !== content.owner)
|
||||
{
|
||||
if(content.private)
|
||||
{
|
||||
setResponseStatus(e, 404);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
content.content = content.content.replace(/%%(.+)%%/g, "");
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
if(session && session.user && session.user.id !== content.owner)
|
||||
{
|
||||
content.content = content.content.replace(/%%(.+)%%/g, "");
|
||||
}
|
||||
|
||||
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,14 +18,30 @@ export default defineEventHandler(async (e) => {
|
||||
'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(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
|
||||
|
||||
if(content !== undefined)
|
||||
{
|
||||
return content;
|
||||
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 { path: content.path,
|
||||
owner: content.owner,
|
||||
title: content.title,
|
||||
type: content.type,
|
||||
};
|
||||
}
|
||||
|
||||
setResponseStatus(e, 404);
|
||||
@@ -1,21 +1,29 @@
|
||||
import useDatabase from '~/composables/useDatabase';
|
||||
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) => {
|
||||
const { user } = await getUserSession(e);
|
||||
|
||||
/*if(!user)
|
||||
{
|
||||
throw createError({ statusCode: 401, statusText: 'Unauthorized' });
|
||||
}*/
|
||||
|
||||
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)
|
||||
{
|
||||
const navigation: Navigation[] = [];
|
||||
const navigation: NavigationTreeItem[] = [];
|
||||
|
||||
for(const idx in content)
|
||||
{
|
||||
@@ -43,9 +51,10 @@ export default defineEventHandler(async (e) => {
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -58,6 +67,11 @@ function addChild(arr: Navigation[], e: Navigation): void
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
84
server/api/project.post.ts
Normal file
84
server/api/project.post.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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) as Array<ProjectItem & { match?: number }>;
|
||||
|
||||
const db = useDatabase();
|
||||
const { ...cols } = getTableColumns(explorerContentTable);
|
||||
const full = db.select(cols).from(explorerContentTable).all() as Record<string, any>[];
|
||||
|
||||
for(let i = full.length - 1; i >= 0; i--)
|
||||
{
|
||||
const item = items.find(e => (e.path === '' ? [e.parent, parsePath(e.name === '' ? e.title : e.name)].filter(e => !!e).join('/') : e.path) === full[i].path);
|
||||
if(item)
|
||||
{
|
||||
item.match = i;
|
||||
full[i].include = true;
|
||||
}
|
||||
}
|
||||
|
||||
db.transaction((tx) => {
|
||||
for(let i = 0; i < items.length; i++)
|
||||
{
|
||||
const item = items[i];
|
||||
const old = full[item.match!];
|
||||
|
||||
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: item.content ?? old.content,
|
||||
}).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(),
|
||||
content: item.content ?? old.content,
|
||||
},
|
||||
target: explorerContentTable.path,
|
||||
}).run();
|
||||
}
|
||||
for(let i = 0; i < full.length; i++)
|
||||
{
|
||||
if(full[i].include !== true)
|
||||
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://d-any.com">
|
||||
<img src="https://d-any.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://d-any.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://d-any.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 { userSessionsTable } from "~/db/schema";
|
||||
import { usersDataTable, userSessionsTable } from "~/db/schema";
|
||||
import { eq, and, sql, lte } from "drizzle-orm";
|
||||
import { refreshSessionFromDB } from "../utils/user";
|
||||
|
||||
@@ -19,9 +19,14 @@ export default defineNitroPlugin(() => {
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.update(userSessionsTable).set({
|
||||
db.update(userSessionsTable).set({
|
||||
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 });
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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: true,
|
||||
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 { 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 { explorerContentTable } from "~/db/schema";
|
||||
import { convertToStorableLinks } from "../api/file.post";
|
||||
|
||||
const typeMapping: Record<string, FileType> = {
|
||||
".md": "markdown",
|
||||
@@ -11,8 +12,8 @@ const typeMapping: Record<string, FileType> = {
|
||||
|
||||
export default defineTask({
|
||||
meta: {
|
||||
name: 'sync',
|
||||
description: 'Synchronise the project with Obsidian',
|
||||
name: 'pull',
|
||||
description: 'Pull the data from Git',
|
||||
},
|
||||
async run(event) {
|
||||
try {
|
||||
@@ -25,10 +26,9 @@ export default defineTask({
|
||||
recursive: true,
|
||||
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: any) => {
|
||||
const files: typeof explorerContentTable.$inferInsert[] = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e, i) => {
|
||||
if(e.type === 'tree')
|
||||
{
|
||||
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('/');
|
||||
return {
|
||||
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,
|
||||
type: 'folder',
|
||||
content: null,
|
||||
owner: '1',
|
||||
navigable: true,
|
||||
private: e.path.startsWith('98.Privé')
|
||||
private: e.path.startsWith('98.Privé'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default defineTask({
|
||||
|
||||
return {
|
||||
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,
|
||||
type: (typeMapping[extension] ?? 'file'),
|
||||
content: reshapeContent(content as string, typeMapping[extension] ?? 'File'),
|
||||
@@ -64,77 +64,24 @@ export default defineTask({
|
||||
}
|
||||
}));
|
||||
|
||||
/*let tags: Tag[] = [];
|
||||
const tagFile = files.find(e => e.path === "tags");
|
||||
|
||||
if(tagFile)
|
||||
const pathList = files.map(e => e.path);
|
||||
files.forEach(e => {
|
||||
if(e.type !== 'folder' && e.content)
|
||||
{
|
||||
const parsed = useMarkdown()(tagFile.content);
|
||||
const titles = parsed.children.filter(e => e.type === 'element' && e.tagName.match(/h\d/));
|
||||
for(let i = 0; i < titles.length; i++)
|
||||
{
|
||||
const start = titles[i].position?.start.offset ?? 0;
|
||||
const end = titles.length === i + 1 ? tagFile.content.length : titles[i + 1].position.start.offset - 1;
|
||||
tags.push({ tag: titles[i].properties.id, description: tagFile.content.substring(titles[i].position?.end.offset + 1, end) });
|
||||
e.content = Buffer.from(convertToStorableLinks(e.content.toString('utf-8'), files.map(e => e.path)), 'utf-8');
|
||||
}
|
||||
}*/
|
||||
})
|
||||
|
||||
const db = useDatabase();
|
||||
db.delete(explorerContentTable).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 };
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.error(e);
|
||||
|
||||
return { result: false, error: e };
|
||||
}
|
||||
},
|
||||
@@ -145,6 +92,9 @@ function reshapeContent(content: string, type: FileType): string | null
|
||||
switch(type)
|
||||
{
|
||||
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":
|
||||
return content;
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
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(),
|
||||
"MM": (date: Date) => padRight((date.getUTCMonth() + 1).toString(), '0', 2),
|
||||
"dd": (date: Date) => padRight(date.getUTCDate().toString(), '0', 2),
|
||||
"mm": (date: Date) => padRight(date.getFullYear().toString(), '0', 2),
|
||||
"HH": (date: Date) => padRight(date.getFullYear().toString(), '0', 2),
|
||||
"ss": (date: Date) => padRight(date.getFullYear().toString(), '0', 2),
|
||||
"mm": (date: Date) => padRight(date.getUTCMinutes().toString(), '0', 2),
|
||||
"HH": (date: Date) => padRight(date.getUTCHours().toString(), '0', 2),
|
||||
"ss": (date: Date) => padRight(date.getUTCSeconds().toString(), '0', 2),
|
||||
};
|
||||
const keys = Object.keys(transforms);
|
||||
|
||||
@@ -40,3 +46,10 @@ export function clamp(x: number, min: number, max: number): number {
|
||||
return min;
|
||||
return x;
|
||||
}
|
||||
|
||||
export const iconByType: Record<FileType, string> = {
|
||||
'folder': 'lucide:folder',
|
||||
'canvas': 'ph:graph-light',
|
||||
'file': 'radix-icons:file',
|
||||
'markdown': 'radix-icons:file',
|
||||
}
|
||||
8
todo.md
Normal file
8
todo.md
Normal file
@@ -0,0 +1,8 @@
|
||||
- [ ] Rename auto des liens au changement de path
|
||||
- [ ] Autocomplete des liens dans l'editeur
|
||||
- [ ] Editeur de graphe
|
||||
- [ ] Filtrage de lien avec le header id
|
||||
- [ ] Embed de lien (le ![[]] de Obsidian)
|
||||
- [ ] Rework la structure projet
|
||||
- [ ] Limite de taille par projet (100 Mo ?)
|
||||
- [ ] Création de projet (!!! limité à 1 par personne au début)
|
||||
2
types/auth.d.ts
vendored
2
types/auth.d.ts
vendored
@@ -31,6 +31,8 @@ export interface UserRawData {
|
||||
|
||||
export interface UserExtendedData {
|
||||
signin: Date;
|
||||
lastTimestamp: Date;
|
||||
logCount: number;
|
||||
}
|
||||
|
||||
export type Permissions = { permissions: string[] };
|
||||
|
||||
Reference in New Issue
Block a user