You've already forked obsidian-visualiser
Compare commits
15 Commits
cbce979aa9
...
rework
| Author | SHA1 | Date | |
|---|---|---|---|
| 60f1fbb4aa | |||
| 42658558c5 | |||
| 057efb848c | |||
| 721e7ff3db | |||
| 41951d7603 | |||
| a392841012 | |||
| b3fae0b5db | |||
| 1af78e5ab7 | |||
| 83ddaf19d4 | |||
| e8b521f122 | |||
| 0105a6aaea | |||
| 633231f821 | |||
| 5ce2d3e236 | |||
| 8a19448a38 | |||
| bd32d176b1 |
47
app.vue
47
app.vue
@@ -1,12 +1,41 @@
|
||||
<template>
|
||||
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
|
||||
<NuxtRouteAnnouncer/>
|
||||
<NuxtLayout>
|
||||
<TooltipProvider>
|
||||
<ToastProvider>
|
||||
<NuxtPage></NuxtPage>
|
||||
</ToastProvider>
|
||||
</TooltipProvider>
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
<NuxtRouteAnnouncer/>
|
||||
<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">
|
||||
<NuxtPage></NuxtPage>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
<Toaster v-model="list" />
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
provideToaster();
|
||||
|
||||
const { list } = useToast();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-light-40;
|
||||
@apply dark:bg-dark-40;
|
||||
@apply rounded-md;
|
||||
@apply border-2;
|
||||
@apply border-solid;
|
||||
@apply border-transparent;
|
||||
@apply bg-clip-padding;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-light-50;
|
||||
@apply dark:bg-dark-50;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +0,0 @@
|
||||
<template>
|
||||
<button class="text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex h-[35px] items-center justify-center bg-light-25 dark:bg-dark-25 px-[15px] leading-none outline-none
|
||||
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
85
components/Editor.vue
Normal file
85
components/Editor.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { dropCursor, crosshairCursor, keymap, EditorView } from '@codemirror/view';
|
||||
import { 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 { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { lintKeymap } from '@codemirror/lint';
|
||||
|
||||
const editor = useTemplateRef('editor');
|
||||
const view = ref<EditorView>();
|
||||
const state = ref<EditorState>();
|
||||
|
||||
const model = defineModel<string>();
|
||||
|
||||
onMounted(() => {
|
||||
if(editor.value)
|
||||
{
|
||||
state.value = EditorState.create({
|
||||
doc: model.value,
|
||||
extensions: [
|
||||
history(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
crosshairCursor(),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap
|
||||
])
|
||||
]
|
||||
});
|
||||
view.value = new EditorView({
|
||||
state: state.value,
|
||||
parent: editor.value,
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (view.value) {
|
||||
view.value?.destroy()
|
||||
view.value = undefined
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (model.value === void 0) {
|
||||
return;
|
||||
}
|
||||
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 || "" }
|
||||
});
|
||||
}
|
||||
});
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cm-editor
|
||||
{
|
||||
@apply bg-transparent;
|
||||
}
|
||||
.cm-editor .cm-content
|
||||
{
|
||||
@apply caret-light-100;
|
||||
@apply dark:caret-dark-100;
|
||||
}
|
||||
</style>
|
||||
26
components/Markdown.vue
Normal file
26
components/Markdown.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<template v-if="content && content.length > 0">
|
||||
<Suspense :timeout="0">
|
||||
<MarkdownRenderer #default :key="key" v-if="node" :node="node" :proses="proses"></MarkdownRenderer>
|
||||
<template #fallback><Loading /></template>
|
||||
</Suspense>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { hash } from 'ohash'
|
||||
|
||||
const { content } = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
proses: {
|
||||
type: Object
|
||||
}
|
||||
})
|
||||
|
||||
const parser = useMarkdown();
|
||||
const key = computed(() => hash(content));
|
||||
const node = computed(() => content ? parser(content) : undefined);
|
||||
</script>
|
||||
111
components/MarkdownRenderer.vue
Normal file
111
components/MarkdownRenderer.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import type { RootContent, Root } from 'hast';
|
||||
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 ProseCode from '~/components/prose/ProseCode.vue';
|
||||
import ProsePre from '~/components/prose/ProsePre.vue';
|
||||
import ProseEm from '~/components/prose/ProseEm.vue';
|
||||
import ProseH1 from '~/components/prose/ProseH1.vue';
|
||||
import ProseH2 from '~/components/prose/ProseH2.vue';
|
||||
import ProseH3 from '~/components/prose/ProseH3.vue';
|
||||
import ProseH4 from '~/components/prose/ProseH4.vue';
|
||||
import ProseH5 from '~/components/prose/ProseH5.vue';
|
||||
import ProseH6 from '~/components/prose/ProseH6.vue';
|
||||
import ProseHr from '~/components/prose/ProseHr.vue';
|
||||
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 ProseStrong from '~/components/prose/ProseStrong.vue';
|
||||
import ProseTable from '~/components/prose/ProseTable.vue';
|
||||
import ProseTag from '~/components/prose/ProseTag.vue';
|
||||
import ProseThead from '~/components/prose/ProseThead.vue';
|
||||
import ProseTbody from '~/components/prose/ProseTbody.vue';
|
||||
import ProseTd from '~/components/prose/ProseTd.vue';
|
||||
import ProseTh from '~/components/prose/ProseTh.vue';
|
||||
import ProseTr from '~/components/prose/ProseTr.vue';
|
||||
import ProseScript from '~/components/prose/ProseScript.vue';
|
||||
|
||||
const proseList = {
|
||||
"p": ProseP,
|
||||
"a": ProseA,
|
||||
"blockquote": ProseBlockquote,
|
||||
"code": ProseCode,
|
||||
"pre": ProsePre,
|
||||
"em": ProseEm,
|
||||
"h1": ProseH1,
|
||||
"h2": ProseH2,
|
||||
"h3": ProseH3,
|
||||
"h4": ProseH4,
|
||||
"h5": ProseH5,
|
||||
"h6": ProseH6,
|
||||
"hr": ProseHr,
|
||||
"img": ProseImg,
|
||||
"ul": ProseUl,
|
||||
"ol": ProseOl,
|
||||
"li": ProseLi,
|
||||
"strong": ProseStrong,
|
||||
"table": ProseTable,
|
||||
"tag": ProseTag,
|
||||
"thead": ProseThead,
|
||||
"tbody": ProseTbody,
|
||||
"td": ProseTd,
|
||||
"th": ProseTh,
|
||||
"tr": ProseTr,
|
||||
"script": ProseScript
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MarkdownRenderer',
|
||||
props: {
|
||||
node: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
proses: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
async setup(props) {
|
||||
if(props.proses)
|
||||
{
|
||||
for(const prose of Object.keys(props.proses))
|
||||
{
|
||||
if(typeof props.proses[prose] === 'string')
|
||||
props.proses[prose] = await resolveComponent(props.proses[prose]);
|
||||
}
|
||||
}
|
||||
return { tags: Object.assign({}, proseList, props.proses) };
|
||||
},
|
||||
render(ctx: any) {
|
||||
const { node, tags } = ctx;
|
||||
|
||||
if(!node)
|
||||
return null;
|
||||
|
||||
return h('div', null, {default: () => (node as Root).children.map(e => renderNode(e, tags)).filter(e => !!e)});
|
||||
}
|
||||
});
|
||||
|
||||
function renderNode(node: RootContent, tags: Record<string, any>): VNode | undefined
|
||||
{
|
||||
if(node.type === 'text' && node.value.length > 0 && node.value !== '\n')
|
||||
{
|
||||
return h(Text, node.value);
|
||||
}
|
||||
else if(node.type === 'comment' && node.value.length > 0 && node.value !== '\n')
|
||||
{
|
||||
return h(Comment, node.value);
|
||||
}
|
||||
else if(node.type === 'element')
|
||||
{
|
||||
return h(tags[node.tagName] ?? node.tagName, { ...node.properties, class: node.properties.className }, { default: () => node.children.map(e => renderNode(e, tags)).filter(e => !!e) });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<SelectItem :value="value" :disabled="disabled" class="">
|
||||
<SelectItemText class="">{{ label }}</SelectItemText>
|
||||
<SelectItemIndicator class="">
|
||||
<Icon icon="radix-icons:check" />
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import { SelectItem } from 'radix-vue';
|
||||
const { disabled = false, value } = defineProps<{
|
||||
disabled?: boolean
|
||||
value: NonNullable<any>
|
||||
label: string
|
||||
}>();
|
||||
</script>
|
||||
16
components/ThemeSwitch.client.vue
Normal file
16
components/ThemeSwitch.client.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const isDark = computed({
|
||||
get() {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set() {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch v-model="isDark" onIcon="radix-icons:moon" offIcon="radix-icons:sun" />
|
||||
</template>
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<ToastRoot :duration="duration" class="ToastRoot grid grid-cols-8 bg-light-10 dark:bg-dark-10 p-3 border border-light-30 dark:border-dark-30" v-model:open="model">
|
||||
<ToastTitle class="font-semibold text-xl col-span-7 text-light-70 dark:text-dark-70" asChild><h4>{{ title }}</h4></ToastTitle>
|
||||
<ToastClose v-if="closeable" aria-label="Close" class="text-2xl -translate-y-1/2 translate-x-1/2 cursor-pointer"><span aria-hidden>×</span></ToastClose>
|
||||
<ToastDescription class="text-md col-span-8 text-light-70 dark:text-dark-70" asChild><span>{{ content }}</span></ToastDescription>
|
||||
</ToastRoot>
|
||||
|
||||
<ToastViewport class="fixed bottom-0 right-0 flex flex-col p-6 gap-2 max-w-[512px] z-50 outline-none min-w-72" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<boolean>();
|
||||
|
||||
const { closeable = true, duration, title, content } = defineProps<{
|
||||
closeable?: boolean
|
||||
duration?: number
|
||||
title: string
|
||||
content: string
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ToastRoot[data-state='open'] {
|
||||
animation: slideIn .15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.ToastRoot[data-state='closed'] {
|
||||
animation: hide .1s ease-in;
|
||||
}
|
||||
.ToastRoot[data-swipe='move'] {
|
||||
transform: translateX(var(--radix-toast-swipe-move-x));
|
||||
}
|
||||
.ToastRoot[data-swipe='cancel'] {
|
||||
transform: translateX(0);
|
||||
transition: transform .2s ease-out;
|
||||
}
|
||||
.ToastRoot[data-swipe='end'] {
|
||||
animation: swipeRight .1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes hide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(calc(100% + var(--viewport-padding)));
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes swipeRight {
|
||||
from {
|
||||
transform: translateX(var(--radix-toast-swipe-end-x));
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
components/base/Avatar.vue
Normal file
22
components/base/Avatar.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<AvatarRoot class="inline-flex h-12 w-12 select-none items-center justify-center overflow-hidden align-middle">
|
||||
<AvatarImage class="h-full w-full object-cover" :src="src" asChild @loading-status-change="(status) => loading = status === 'loading'">
|
||||
<img :src="src" />
|
||||
</AvatarImage>
|
||||
<AvatarFallback :delay-ms="0" class="text-light-100 dark:text-dark-100 leading-1 flex h-full w-full p-4 items-center justify-center bg-light-25 dark:bg-dark-25 font-medium">
|
||||
<Loading v-if="loading" />
|
||||
<Icon v-else-if="!!icon" :icon="icon" class="w-full h-full" />
|
||||
<span v-else-if="!!text">{{ text }}</span>
|
||||
</AvatarFallback>
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
const { src, icon, text } = defineProps<{
|
||||
src: string
|
||||
icon?: string
|
||||
text?: string
|
||||
}>();
|
||||
const loading = ref(true);
|
||||
</script>
|
||||
18
components/base/Button.vue
Normal file
18
components/base/Button.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<button :disabled="disabled" class="text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
|
||||
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50"
|
||||
:class="{'p-1': loading || icon, 'h-[35px] px-[15px]': !loading && !icon}" @click="!loading && emit('click')">
|
||||
<Loading v-if="loading" />
|
||||
<slot v-else />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { icon = false, loading = false, disabled = false } = defineProps<{
|
||||
icon?: boolean
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}>();
|
||||
const emit = defineEmits(['click']);
|
||||
</script>
|
||||
46
components/base/Collapsible.vue
Normal file
46
components/base/Collapsible.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<CollapsibleRoot v-model:open="model" :disabled="disabled">
|
||||
<div class="flex flex-row justify-center items-center">
|
||||
<span v-if="!!label">{{ label }}</span>
|
||||
<CollapsibleTrigger class="ms-4" asChild>
|
||||
<Button icon :disabled="disabled">
|
||||
<Icon v-if="model" icon="radix-icons:cross-2" class="h-4 w-4" />
|
||||
<Icon v-else icon="radix-icons:row-spacing" class="h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<slot name="alwaysVisible"></slot>
|
||||
<CollapsibleContent class="overflow-hidden data-[state=closed]:animate-[collapseClose_0.2s_ease-in-out] data-[state=open]:animate-[collapseOpen_0.2s_ease-in-out]">
|
||||
<slot></slot>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
const { label, disabled = false } = defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}>();
|
||||
const model = defineModel<boolean>();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes collapseOpen {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes collapseClose {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<DialogRoot v-if="!priority">
|
||||
<DialogTrigger asChild><Button>{{ label }}</Button></DialogTrigger>
|
||||
<DialogRoot v-if="!priority" v-model="model">
|
||||
<DialogTrigger asChild><Button v-if="!!label">{{ label }}</Button><slot name="trigger" /></DialogTrigger>
|
||||
<DialogPortal v-if="!disabled">
|
||||
<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-[450px] 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">
|
||||
@@ -13,8 +13,8 @@
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
<AlertDialogRoot v-else>
|
||||
<AlertDialogTrigger asChild><Button>{{ label }}</Button></AlertDialogTrigger>
|
||||
<AlertDialogRoot v-else v-model="model">
|
||||
<AlertDialogTrigger asChild><Button v-if="!!label">{{ label }}</Button><slot name="trigger" /></AlertDialogTrigger>
|
||||
<AlertDialogPortal v-if="!disabled">
|
||||
<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-[450px] 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">
|
||||
@@ -35,4 +35,5 @@ const { label, title, description, priority = false, disabled = false, iconClose
|
||||
disabled?: boolean
|
||||
iconClose?: boolean
|
||||
}>();
|
||||
const model = defineModel();
|
||||
</script>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<HoverCardRoot :open-delay="delay">
|
||||
<HoverCardTrigger class="inline-block cursor-help outline-none" asChild>
|
||||
<HoverCardTrigger class="inline-block cursor-help outline-none">
|
||||
<slot></slot>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal v-if="!disabled">
|
||||
<HoverCardContent :side="side" class="data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 p-5 data-[state=open]:transition-all text-light-100 dark:text-dark-100" >
|
||||
<HoverCardContent :class="$attrs.class" :side="side" class="data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 p-5 data-[state=open]:transition-all text-light-100 dark:text-dark-100" >
|
||||
<slot name="content"></slot>
|
||||
<HoverCardArrow class="fill-light-35 dark:fill-dark-35" />
|
||||
</HoverCardContent>
|
||||
9
components/base/Loading.vue
Normal file
9
components/base/Loading.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<span :class="{'w-6 h-6 border-4 after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 border-2 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 border-[6px] after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}" class="rounded-full border-light-35 dark:border-dark-35 after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin"></span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { size = 'normal' } = defineProps<{
|
||||
size?: 'small' | 'normal' | 'large'
|
||||
}>();
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<Label>{{ label }}
|
||||
<SelectRoot :v-model="model">
|
||||
<Label class="py-4 flex flex-row justify-center items-center">
|
||||
<span>{{ 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
|
||||
data-[placeholder]:text-light-50 dark:data-[placeholder]:text-dark-50 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
@@ -15,8 +16,8 @@
|
||||
<SelectScrollUpButton>
|
||||
<Icon icon="radix-icons:chevron-up" />
|
||||
</SelectScrollUpButton>
|
||||
<SelectViewport class="p-1">
|
||||
<slot></slot>
|
||||
<SelectViewport>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton>
|
||||
<Icon icon="radix-icons:chevron-down" />
|
||||
@@ -28,6 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SelectContent, SelectPortal, SelectRoot, SelectScrollDownButton, SelectScrollUpButton, SelectTrigger, SelectValue, SelectViewport } from 'radix-vue'
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
const { placeholder, disabled = false, position = 'popper', label } = defineProps<{
|
||||
placeholder?: string
|
||||
@@ -35,5 +37,5 @@ const { placeholder, disabled = false, position = 'popper', label } = defineProp
|
||||
position?: 'item-aligned' | 'popper'
|
||||
label?: string
|
||||
}>();
|
||||
const model = defineModel();
|
||||
const model = defineModel<string>();
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<SelectGroup :disabled="disabled" class="">
|
||||
<SelectLabel class="">{{ label }}</SelectLabel>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
|
||||
18
components/base/SelectItem.vue
Normal file
18
components/base/SelectItem.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<SelectItem :value="value" :disabled="disabled" class="text-base py-2 leading-none text-light-60 dark:text-dark-60 flex items-center px-6 relative select-none data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-light-30 dark:data-[highlighted]:bg-dark-30 data-[highlighted]:text-light-100 dark:data-[highlighted]:text-dark-100">
|
||||
<SelectItemText class="">{{ label }}</SelectItemText>
|
||||
<SelectItemIndicator class="absolute left-1 w-4 inline-flex items-center justify-center">
|
||||
<Icon icon="radix-icons:check" />
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import { SelectItem, SelectItemIndicator, SelectItemText } from 'radix-vue'
|
||||
const { disabled = false, value } = defineProps<{
|
||||
disabled?: boolean
|
||||
value: NonNullable<any>
|
||||
label: string
|
||||
}>();
|
||||
</script>
|
||||
@@ -1,20 +1,25 @@
|
||||
<template>
|
||||
<Label class="flex justify-center items-center my-2">{{ label }}
|
||||
<SwitchRoot v-model:checked="model" :disabled="disabled"
|
||||
class="mx-3 w-12 h-6 select-none transition-colors 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 transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20">
|
||||
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
|
||||
data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative">
|
||||
<SwitchThumb
|
||||
class="block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 data-[state=checked]:translate-x-[26px]
|
||||
data-[disabled]:bg-light-30 dark:data-[disabled]:bg-dark-30 data-[disabled]:border-light-30 dark:data-[disabled]:border-dark-30" />
|
||||
<Icon v-if="onIcon && offIcon" :icon="onIcon" class="group-data-[state=checked]:opacity-100 group-data-[state=unchecked]:opacity-0 absolute top-1 left-1 transition-opacity" />
|
||||
<Icon v-if="onIcon && offIcon" :icon="offIcon" class="group-data-[state=checked]:opacity-0 group-data-[state=unchecked]:opacity-100 absolute top-1 right-1 transition-opacity" />
|
||||
</SwitchRoot>
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { label, disabled } = defineProps<{
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
const { label, disabled, onIcon, offIcon } = defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
onIcon?: string
|
||||
offIcon?: string
|
||||
}>();
|
||||
const model = defineModel<boolean>();
|
||||
</script>
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<Label class="px-4 my-2">{{ label }}
|
||||
<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>
|
||||
<input :placeholder="placeholder" :disabled="disabled"
|
||||
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">
|
||||
:type="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs">
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
90
components/base/Toaster.vue
Normal file
90
components/base/Toaster.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<ToastProvider>
|
||||
<ToastRoot v-for="toast in model" :key="toast.id" :duration="toast.duration" class="ToastRoot bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 group" :open="toast.state ?? true" @update:open="(state: boolean) => tryClose(toast, state)" :data-type="toast.type ?? 'info'">
|
||||
<div class="grid grid-cols-8 px-3 pt-2 pb-2">
|
||||
<ToastTitle v-if="toast.title" class="font-semibold text-xl col-span-7 text-light-70 dark:text-dark-70" asChild><h4>{{ toast.title }}</h4></ToastTitle>
|
||||
<ToastClose v-if="toast.closeable" aria-label="Close" class="text-xl -translate-y-2 translate-x-4 cursor-pointer"><span aria-hidden>×</span></ToastClose>
|
||||
<ToastDescription v-if="toast.content" class="text-sm col-span-8 text-light-70 dark:text-dark-70" asChild><span>{{ toast.content }}</span></ToastDescription>
|
||||
</div>
|
||||
<TimerProgress v-if="toast.timer" shape="thin" :delay="toast.duration" class="mb-0 mt-0 w-full group-data-[type=error]:bg-light-redBack dark:group-data-[type=error]:bg-dark-redBack group-data-[type=error]:*:bg-light-red dark:group-data-[type=error]:*:bg-dark-red
|
||||
group-data-[type=success]:bg-light-greenBack dark:group-data-[type=success]:bg-dark-greenBack group-data-[type=success]:*:bg-light-green dark:group-data-[type=success]:*:bg-dark-green" @finish="() => tryClose(toast, false)" />
|
||||
</ToastRoot>
|
||||
|
||||
<ToastViewport class="fixed bottom-0 right-0 flex flex-col p-6 gap-2 max-w-[512px] z-50 outline-none min-w-72" />
|
||||
</ToastProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<ExtraToastConfig[]>();
|
||||
|
||||
function tryClose(config: ExtraToastConfig, state: boolean)
|
||||
{
|
||||
if(!state)
|
||||
{
|
||||
const m = model.value;
|
||||
if(m)
|
||||
{
|
||||
const idx = m?.findIndex(e => e.id === config.id);
|
||||
m[idx].state = false;
|
||||
model.value = m;
|
||||
}
|
||||
setTimeout(() => model.value?.splice(model.value?.findIndex(e => e.id === config.id), 1), 500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ToastRoot[data-type='error'] {
|
||||
@apply border-light-red;
|
||||
@apply dark:border-dark-red;
|
||||
@apply bg-light-redBack;
|
||||
@apply dark:bg-dark-redBack;
|
||||
}
|
||||
.ToastRoot[data-type='success'] {
|
||||
@apply border-light-green;
|
||||
@apply dark:border-dark-green;
|
||||
@apply bg-light-greenBack;
|
||||
@apply dark:bg-dark-greenBack;
|
||||
}
|
||||
.ToastRoot[data-state='open'] {
|
||||
animation: slideIn .15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.ToastRoot[data-state='closed'] {
|
||||
animation: hide .1s ease-in;
|
||||
}
|
||||
.ToastRoot[data-swipe='move'] {
|
||||
transform: translateX(var(--radix-toast-swipe-move-x));
|
||||
}
|
||||
.ToastRoot[data-swipe='cancel'] {
|
||||
transform: translateX(0);
|
||||
transition: transform .2s ease-out;
|
||||
}
|
||||
.ToastRoot[data-swipe='end'] {
|
||||
animation: swipeRight .1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes hide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(calc(100% + var(--viewport-padding)));
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes swipeRight {
|
||||
from {
|
||||
transform: translateX(var(--radix-toast-swipe-end-x));
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
<span tabindex="0"><slot></slot></span>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent class="TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70" :side="side" :side-offset="['left', 'right'].includes(side ?? '') ? 8 : 0">
|
||||
<TooltipContent class="TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50" :side="side" :side-offset="['left', 'right'].includes(side ?? '') ? 8 : 0">
|
||||
{{ message }}
|
||||
<TooltipArrow class="fill-light-30 dark:fill-dark-30"></TooltipArrow>
|
||||
</TooltipContent>
|
||||
50
components/base/Tree.vue
Normal file
50
components/base/Tree.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<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">
|
||||
<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>
|
||||
</TreeItem>
|
||||
</TreeRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
|
||||
interface TreeItem
|
||||
{
|
||||
label: string
|
||||
link?: string
|
||||
tag?: string
|
||||
children?: TreeItem[]
|
||||
}
|
||||
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>
|
||||
228
components/canvas/Canvas.vue
Normal file
228
components/canvas/Canvas.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
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';
|
||||
|
||||
interface Props
|
||||
{
|
||||
canvas: CanvasContent;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
|
||||
const canvas = useTemplateRef('canvas');
|
||||
|
||||
const reset = (_: MouseEvent) => {
|
||||
zoom.value = minZoom.value;
|
||||
|
||||
dispX.value = 0;
|
||||
dispY.value = 0;
|
||||
}
|
||||
function edgePos(side: 'bottom' | 'top' | 'left' | 'right', pos: { x: number, y: number }, offset: number): { x: number, y: number } {
|
||||
switch (side) {
|
||||
case "left":
|
||||
return {
|
||||
x: pos.x - offset,
|
||||
y: pos.y
|
||||
};
|
||||
case "right":
|
||||
return {
|
||||
x: pos.x + offset,
|
||||
y: pos.y
|
||||
};
|
||||
case "top":
|
||||
return {
|
||||
x: pos.x,
|
||||
y: pos.y - offset
|
||||
};
|
||||
case "bottom":
|
||||
return {
|
||||
x: pos.x,
|
||||
y: pos.y + offset
|
||||
}
|
||||
}
|
||||
}
|
||||
function getNode(id: string): CanvasNode | undefined
|
||||
{
|
||||
return props.canvas.nodes.find(e => e.id === id);
|
||||
}
|
||||
function posFromDir(e: { minX: number, minY: number, maxX: number, maxY: number }, t: 'bottom' | 'top' | 'left' | 'right'): { x: number, y: number } {
|
||||
switch (t) {
|
||||
case "top":
|
||||
return { x: (e.minX + e.maxX) / 2, y: e.minY };
|
||||
case "right":
|
||||
return { x: e.maxX, y: (e.minY + e.maxY) / 2 };
|
||||
case "bottom":
|
||||
return { x: (e.minX + e.maxX) / 2, y: e.maxY };
|
||||
case "left":
|
||||
return { x: e.minX, y: (e.minY + e.maxY) / 2 };
|
||||
}
|
||||
}
|
||||
function getBbox(node: CanvasNode): { minX: number, minY: number, maxX: number, maxY: number } {
|
||||
return { minX: node.x, minY: node.y, maxX: node.x + node.width, maxY: node.y + node.height };
|
||||
}
|
||||
function path(from: CanvasNode, fromSide: 'bottom' | 'top' | 'left' | 'right', to: CanvasNode, toSide: 'bottom' | 'top' | 'left' | 'right'): any {
|
||||
if(from === undefined || to === undefined)
|
||||
{
|
||||
return {
|
||||
path: '',
|
||||
from: {},
|
||||
to: {},
|
||||
toSide: '',
|
||||
}
|
||||
}
|
||||
const start = posFromDir(getBbox(from), fromSide), end = posFromDir(getBbox(to), toSide);
|
||||
return bezier(start, fromSide, end, toSide);
|
||||
}
|
||||
function bezier(from: { x: number, y: number }, fromSide: 'bottom' | 'top' | 'left' | 'right', to: { x: number, y: number }, toSide: 'bottom' | 'top' | 'left' | 'right'): any {
|
||||
const r = Math.hypot(from.x - to.x, from.y - to.y), o = clamp(r / 2, 70, 150), a = edgePos(fromSide, from, o), s = edgePos(toSide, to, o);
|
||||
return {
|
||||
path: `M${from.x},${from.y} C${a.x},${a.y} ${s.x},${s.y} ${to.x},${to.y}`,
|
||||
from: from,
|
||||
to: to,
|
||||
side: toSide,
|
||||
};
|
||||
}
|
||||
function labelCenter(from: CanvasNode, fromSide: 'bottom' | 'top' | 'left' | 'right', to: CanvasNode, toSide: 'bottom' | 'top' | 'left' | 'right'): string {
|
||||
const start = posFromDir(getBbox(from), fromSide), end = posFromDir(getBbox(to), toSide);
|
||||
const len = Math.hypot(start.x - end.x, start.y - end.y), offset = clamp(len / 2, 70, 150), b = edgePos(fromSide, start, offset), s = edgePos(toSide, end, offset);
|
||||
const center = getCenter(start, end, b, s, 0.5);
|
||||
return `translate(${center.x}px, ${center.y}px)`;
|
||||
}
|
||||
function getCenter(n: { x: number, y: number }, i: { x: number, y: number }, r: { x: number, y: number }, o: { x: number, y: number }, e: number): { x: number, y: number } {
|
||||
const a = 1 - e, s = a * a * a, l = 3 * e * a * a, c = 3 * e * e * a, u = e * e * e;
|
||||
return {
|
||||
x: s * n.x + l * r.x + c * o.x + u * i.x,
|
||||
y: s * n.y + l * r.y + c * o.y + u * i.y
|
||||
};
|
||||
}
|
||||
/*
|
||||
|
||||
stroke-light-red
|
||||
stroke-light-orange
|
||||
stroke-light-yellow
|
||||
stroke-light-green
|
||||
stroke-light-cyan
|
||||
stroke-light-purple
|
||||
dark:stroke-dark-red
|
||||
dark:stroke-dark-orange
|
||||
dark:stroke-dark-yellow
|
||||
dark:stroke-dark-green
|
||||
dark:stroke-dark-cyan
|
||||
dark:stroke-dark-purple
|
||||
fill-light-red
|
||||
fill-light-orange
|
||||
fill-light-yellow
|
||||
fill-light-green
|
||||
fill-light-cyan
|
||||
fill-light-purple
|
||||
dark:fill-dark-red
|
||||
dark:fill-dark-orange
|
||||
dark:fill-dark-yellow
|
||||
dark:fill-dark-green
|
||||
dark:fill-dark-cyan
|
||||
dark:fill-dark-purple
|
||||
bg-light-red
|
||||
bg-light-orange
|
||||
bg-light-yellow
|
||||
bg-light-green
|
||||
bg-light-cyan
|
||||
bg-light-purple
|
||||
dark:bg-dark-red
|
||||
dark:bg-dark-orange
|
||||
dark:bg-dark-yellow
|
||||
dark:bg-dark-green
|
||||
dark:bg-dark-cyan
|
||||
dark:bg-dark-purple
|
||||
border-light-red
|
||||
border-light-orange
|
||||
border-light-yellow
|
||||
border-light-green
|
||||
border-light-cyan
|
||||
border-light-purple
|
||||
dark:border-dark-red
|
||||
dark:border-dark-orange
|
||||
dark:border-dark-yellow
|
||||
dark:border-dark-green
|
||||
dark:border-dark-cyan
|
||||
dark:border-dark-purple
|
||||
|
||||
*/
|
||||
|
||||
const dragHandler = useDrag(({ event: Event, delta: [x, y] }: { event: Event, delta: number[] }) => {
|
||||
event?.preventDefault();
|
||||
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();
|
||||
zoom.value = clamp(zoom.value + y * -0.001, minZoom.value, 3);
|
||||
}, {
|
||||
domTarget: canvas,
|
||||
eventOptions: { passive: false, }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Suspense>
|
||||
<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">
|
||||
<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" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip message="Reset" side="right">
|
||||
<div @click="zoom = 1" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||
<Icon icon="radix-icons:reload" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip message="Tout contenir" side="right">
|
||||
<div @click="reset" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||
<Icon icon="radix-icons:corners" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip message="Zoom arrière" side="right">
|
||||
<div @click="zoom = clamp(zoom * 0.9, 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:minus" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="absolute top-0 left-0 w-full h-full origin-center pointer-events-none *:pointer-events-auto *:select-none"
|
||||
:style="{transform: `scale(${zoom}) translate(${dispX}px, ${dispY}px)`}">
|
||||
<div>
|
||||
<CanvasNode v-for="node of props.canvas.nodes" :key="node.id" :node="node" :zoom="zoom" />
|
||||
</div>
|
||||
<template v-for="edge of props.canvas.edges">
|
||||
<div :key="edge.id" v-if="edge.label" class="absolute z-10"
|
||||
:style="{ transform: labelCenter(getNode(edge.fromNode)!, edge.fromSide, getNode(edge.toNode)!, edge.toSide) }">
|
||||
<div class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 -translate-x-[50%] -translate-y-[50%]">{{ edge.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<svg class="absolute top-0 left-0 overflow-visible w-full h-full origin-top pointer-events-none">
|
||||
<CanvasEdge v-for="edge of props.canvas.edges" :key="edge.id"
|
||||
:path="path(getNode(edge.fromNode)!, edge.fromSide, getNode(edge.toNode)!, edge.toSide)"
|
||||
:color="edge.color" :label="edge.label" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #fallback>
|
||||
<div class="loading"></div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
33
components/canvas/CanvasEdge.vue
Normal file
33
components/canvas/CanvasEdge.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { CanvasColor } from "~/types/canvas";
|
||||
|
||||
type Direction = 'bottom' | 'top' | 'left' | 'right';
|
||||
interface Props
|
||||
{
|
||||
path: {
|
||||
path: string;
|
||||
from: { x: number; y: number };
|
||||
to: { x: number; y: number };
|
||||
side: Direction;
|
||||
};
|
||||
color?: CanvasColor;
|
||||
label?: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const rotation: Record<Direction, string> = {
|
||||
top: "180",
|
||||
bottom: "0",
|
||||
left: "90",
|
||||
right: "270"
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<g :style="{'--canvas-color': color?.hex}" class="z-0">
|
||||
<path :style="`stroke-linecap: butt; stroke-width: calc(3px * var(--zoom-multiplier));`" :class="color?.class ? `stroke-light-${color.class} dark:stroke-dark-${color.class}` : ((color && color?.hex !== undefined) ? 'stroke-[color:var(--canvas-color)]' : 'stroke-light-40 dark:stroke-dark-40')" class="fill-none stroke-[4px]" :d="path.path"></path>
|
||||
<g :style="`transform: translate(${path.to.x}px, ${path.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[path.side]}deg);`">
|
||||
<polygon :class="color?.class ? `fill-light-${color.class} dark:fill-dark-${color.class}` : ((color && color?.hex !== undefined) ? 'fill-[color:var(--canvas-color)]' : 'fill-light-40 dark:fill-dark-40')" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
|
||||
</g>
|
||||
</g>
|
||||
</template>
|
||||
52
components/canvas/CanvasNode.vue
Normal file
52
components/canvas/CanvasNode.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import type { CanvasNode } from '~/types/canvas';
|
||||
|
||||
interface Props {
|
||||
node: CanvasNode;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const size = Math.max(props.node.width, props.node.height);
|
||||
const colors = computed(() => {
|
||||
if(props.node.color)
|
||||
{
|
||||
const color = props.node.color;
|
||||
return color?.class ? { bg: `bg-light-${color?.class} dark:bg-dark-${color?.class}`, border: `border-light-${color?.class} dark:border-dark-${color?.class}`} : { bg: `bg-colored`, border: `border-[color:var(--canvas-color)]` };
|
||||
}
|
||||
else
|
||||
{
|
||||
return { border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40` };
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bg-colored
|
||||
{
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(from var(--canvas-color) r g b / var(--tw-bg-opacity));
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="absolute" :style="{transform: `translate(${node.x}px, ${node.y}px)`, width: `${node.width}px`, height: `${node.height}px`, '--canvas-color': node.color?.hex}" :class="{'-z-10': node.type === 'group', 'z-10': node.type !== 'group'}">
|
||||
<div :class="[colors.border]" class="border-2 bg-light-20 dark:bg-dark-20 overflow-hidden contain-strict w-full h-full flex">
|
||||
<div class="w-full h-full py-2 px-4 flex !bg-opacity-[0.07]" :class="colors.bg">
|
||||
<template v-if="node.type === 'group' || zoom > Math.min(0.4, 1000 / size)">
|
||||
<div v-if="node.text?.length > 0" class="flex items-center">
|
||||
<Markdown :content="node.text" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-1 justify-center items-center bg-light-30 dark:bg-dark-30">
|
||||
<Icon icon="radix-icons:text-align-left" class="w-8 h-8"/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="node.type === 'group' && node.label !== undefined" :class="[colors.border]" style="max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))" class="origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] mb-2 px-2 py-1 font-thin">{{ node.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
3
components/prose/FakeA.vue
Normal file
3
components/prose/FakeA.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<span class="text-accent-blue inline-flex items-center cursor-pointer hover:text-opacity-85"><slot v-bind="$attrs"></slot></span>
|
||||
</template>
|
||||
60
components/prose/ProseA.vue
Normal file
60
components/prose/ProseA.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<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'}">
|
||||
<template #content>
|
||||
<template v-if="data[0].type === 'markdown'">
|
||||
<div class="px-10">
|
||||
<Markdown :content="data[0].content" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="data[0].type === 'canvas'">
|
||||
<div class="w-[600px] h-[600px] relative">
|
||||
<Canvas :canvas="JSON.parse(data[0].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]" />
|
||||
</template>
|
||||
</HoverCard>
|
||||
</NuxtLink>
|
||||
<NuxtLink no-prefetch 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()}`" />
|
||||
</NuxtLink>
|
||||
<slot :class="class" v-else v-bind="$attrs"></slot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseURL } from 'ufo';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
|
||||
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);
|
||||
|
||||
if(!!pathname && !protocol)
|
||||
{
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await $fetch(`/api/file`, {
|
||||
query: {
|
||||
search: `%${pathname}`
|
||||
},
|
||||
});
|
||||
} catch(e) { }
|
||||
loading.value = false;
|
||||
}
|
||||
</script>
|
||||
179
components/prose/ProseBlockquote.vue
Normal file
179
components/prose/ProseBlockquote.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<blockquote 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>
|
||||
3
components/prose/ProseCode.vue
Normal file
3
components/prose/ProseCode.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<code><slot /></code>
|
||||
</template>
|
||||
5
components/prose/ProseEm.vue
Normal file
5
components/prose/ProseEm.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<em>
|
||||
<slot />
|
||||
</em>
|
||||
</template>
|
||||
9
components/prose/ProseH1.vue
Normal file
9
components/prose/ProseH1.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<h1 :id="parseId(id)" class="text-5xl font-thin mt-3 mb-8 first:pt-0 pt-2 relative lg:right-8 sm:right-4 right-2">
|
||||
<slot />
|
||||
</h1>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ id?: string }>()
|
||||
</script>
|
||||
11
components/prose/ProseH2.vue
Normal file
11
components/prose/ProseH2.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<h2 :id="parseId(id)" class="text-4xl font-semibold mt-3 mb-6 ms-1 first:pt-0 pt-2 relative sm:right-4 right-2">
|
||||
<slot />
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
</script>
|
||||
11
components/prose/ProseH3.vue
Normal file
11
components/prose/ProseH3.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<h3 :id="parseId(id)" class="text-2xl font-bold mt-2 mb-4">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
</script>
|
||||
9
components/prose/ProseH4.vue
Normal file
9
components/prose/ProseH4.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<h4 :id="parseId(id)" class="text-xl font-semibold my-2" style="font-variant: small-caps;">
|
||||
<slot />
|
||||
</h4>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ id?: string }>()
|
||||
</script>
|
||||
11
components/prose/ProseH5.vue
Normal file
11
components/prose/ProseH5.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<h5 :id="parseId(id)" class="text-lg font-semibold my-1">
|
||||
<slot />
|
||||
</h5>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
</script>
|
||||
11
components/prose/ProseH6.vue
Normal file
11
components/prose/ProseH6.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<h6 :id="parseId(id)">
|
||||
<slot />
|
||||
</h6>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const generate = computed(() => props.id)
|
||||
</script>
|
||||
3
components/prose/ProseHr.vue
Normal file
3
components/prose/ProseHr.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<Separator class="border-light-35 dark:border-dark-35 m-4" />
|
||||
</template>
|
||||
42
components/prose/ProseImg.vue
Normal file
42
components/prose/ProseImg.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<img
|
||||
:src="refinedSrc"
|
||||
:alt="alt"
|
||||
:width="width"
|
||||
:height="height"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { withTrailingSlash, withLeadingSlash, joinURL } from 'ufo'
|
||||
import { useRuntimeConfig, computed, resolveComponent } from '#imports'
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: undefined
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: undefined
|
||||
}
|
||||
})
|
||||
|
||||
const refinedSrc = computed(() => {
|
||||
if (props.src?.startsWith('/') && !props.src.startsWith('//')) {
|
||||
const _base = withLeadingSlash(withTrailingSlash(useRuntimeConfig().app.baseURL))
|
||||
if (_base !== '/' && !props.src.startsWith(_base)) {
|
||||
return joinURL(_base, props.src)
|
||||
}
|
||||
}
|
||||
return props.src
|
||||
})
|
||||
</script>
|
||||
3
components/prose/ProseLi.vue
Normal file
3
components/prose/ProseLi.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<li class="before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4"><slot /></li>
|
||||
</template>
|
||||
5
components/prose/ProseOl.vue
Normal file
5
components/prose/ProseOl.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<ol>
|
||||
<slot />
|
||||
</ol>
|
||||
</template>
|
||||
12
components/prose/ProseP.vue
Normal file
12
components/prose/ProseP.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<p><slot /></p>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.text-comment
|
||||
{
|
||||
@apply text-light-50;
|
||||
@apply dark:text-dark-50;
|
||||
@apply text-sm;
|
||||
}
|
||||
</style>
|
||||
36
components/prose/ProsePre.vue
Normal file
36
components/prose/ProsePre.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<pre :class="$props.class"><slot /></pre>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
code: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
filename: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
highlights: {
|
||||
type: Array as () => number[],
|
||||
default: () => []
|
||||
},
|
||||
meta: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
class: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
pre code .line{display:block}
|
||||
</style>
|
||||
15
components/prose/ProseScript.vue
Normal file
15
components/prose/ProseScript.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div v-if="isDev">
|
||||
Rendering the <code>script</code> element is dangerous and is disabled by default. Consider implementing your own <code>ProseScript</code> element to have control over script rendering.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
const isDev = import.meta.dev
|
||||
</script>
|
||||
5
components/prose/ProseStrong.vue
Normal file
5
components/prose/ProseStrong.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<strong>
|
||||
<slot />
|
||||
</strong>
|
||||
</template>
|
||||
5
components/prose/ProseTable.vue
Normal file
5
components/prose/ProseTable.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<table class="mx-4 my-8 border-collapse border border-light-35 dark:border-dark-35">
|
||||
<slot />
|
||||
</table>
|
||||
</template>
|
||||
57
components/prose/ProseTag.vue
Normal file
57
components/prose/ProseTag.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<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> -->
|
||||
5
components/prose/ProseTbody.vue
Normal file
5
components/prose/ProseTbody.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<tbody>
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
5
components/prose/ProseTd.vue
Normal file
5
components/prose/ProseTd.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<td class="border border-light-35 dark:border-dark-35 py-1 px-2">
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
5
components/prose/ProseTh.vue
Normal file
5
components/prose/ProseTh.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-4 first:pt-0">
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
5
components/prose/ProseThead.vue
Normal file
5
components/prose/ProseThead.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<thead>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
5
components/prose/ProseTr.vue
Normal file
5
components/prose/ProseTr.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<tr>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
5
components/prose/ProseUl.vue
Normal file
5
components/prose/ProseUl.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<ul>
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
@@ -1,13 +1,19 @@
|
||||
import ".dotenv/config";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import * as schema from '../db/schema';
|
||||
|
||||
let instance: BunSQLiteDatabase<typeof schema>;
|
||||
export default function useDatabase()
|
||||
{
|
||||
const sqlite = new Database(process.env.DB_FILE);
|
||||
const db = drizzle({ client: sqlite });
|
||||
if(!instance)
|
||||
{
|
||||
const database = useRuntimeConfig().database;
|
||||
const sqlite = new Database(database);
|
||||
instance = drizzle({ client: sqlite, schema });
|
||||
|
||||
db.run("PRAGMA journal_mode = WAL;");
|
||||
instance.run("PRAGMA journal_mode = WAL;");
|
||||
instance.run("PRAGMA foreign_keys = true;");
|
||||
}
|
||||
|
||||
return db;
|
||||
return instance;
|
||||
}
|
||||
29
composables/useMarkdown.ts
Normal file
29
composables/useMarkdown.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { unified, type Processor } from "unified";
|
||||
import type { Root } from 'hast';
|
||||
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
|
||||
{
|
||||
let processor: Processor;
|
||||
|
||||
const parse = (markdown: string) => {
|
||||
if (!processor)
|
||||
{
|
||||
processor = unified().use([RemarkParse, RemarkGfm , RemarkOfm , RemarkBreaks, RemarkFrontmatter]);
|
||||
processor.use(RemarkRehype, { allowDangerousHtml: true });
|
||||
processor.use(RehypeRaw);
|
||||
}
|
||||
|
||||
const processed = processor.runSync(processor.parse(markdown)) as Root;
|
||||
return processed;
|
||||
}
|
||||
|
||||
return parse;
|
||||
}
|
||||
40
composables/useToast.ts
Normal file
40
composables/useToast.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface ToastConfig
|
||||
{
|
||||
closeable?: boolean
|
||||
duration: number
|
||||
title?: string
|
||||
content?: string
|
||||
timer?: boolean
|
||||
type?: ToastType
|
||||
}
|
||||
export type ToastType = 'info' | 'success' | 'error';
|
||||
export type ExtraToastConfig = ToastConfig & { id: string, state: boolean };
|
||||
|
||||
let id = 0;
|
||||
|
||||
const [provideToaster, useToast] = createInjectionState(() => {
|
||||
const list = ref<ExtraToastConfig[]>([]);
|
||||
|
||||
function add(config: ToastConfig)
|
||||
{
|
||||
list.value.push({ ...config, id: (++id).toString(), state: true, });
|
||||
}
|
||||
function clear(type?: ToastType)
|
||||
{
|
||||
list.value.forEach(e => { if(e.type !== type) { e.state = false; } });
|
||||
}
|
||||
|
||||
return { list, add, clear }
|
||||
}, { injectionKey: Symbol('toaster') });
|
||||
|
||||
export { provideToaster, useToastWithDefault as useToast };
|
||||
|
||||
function useToastWithDefault()
|
||||
{
|
||||
const toasts = useToast();
|
||||
if(!toasts)
|
||||
{
|
||||
return { list: ref<ExtraToastConfig[]>([]), add: () => {}, clear: () => {} };
|
||||
}
|
||||
return toasts;
|
||||
}
|
||||
40
composables/useUserSession.ts
Normal file
40
composables/useUserSession.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { UserSession, UserSessionComposable } from '~/types/auth'
|
||||
|
||||
const useSessionState = () => useState<UserSession>('nuxt-session', () => ({}))
|
||||
const useAuthReadyState = () => useState('nuxt-auth-ready', () => false)
|
||||
|
||||
/**
|
||||
* Composable to get back the user session and utils around it.
|
||||
* @see https://github.com/atinux/nuxt-auth-utils
|
||||
*/
|
||||
export function useUserSession(): UserSessionComposable {
|
||||
const sessionState = useSessionState()
|
||||
const authReadyState = useAuthReadyState()
|
||||
return {
|
||||
ready: computed(() => authReadyState.value),
|
||||
loggedIn: computed(() => Boolean(sessionState.value.user)),
|
||||
user: computed(() => sessionState.value.user || null),
|
||||
session: sessionState,
|
||||
fetch,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetch() {
|
||||
const authReadyState = useAuthReadyState()
|
||||
useSessionState().value = await useRequestFetch()('/api/auth/session', {
|
||||
headers: {
|
||||
Accept: 'text/json',
|
||||
},
|
||||
retry: false,
|
||||
}).catch(() => ({}))
|
||||
if (!authReadyState.value) {
|
||||
authReadyState.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
await $fetch('/api/auth/session', { method: 'DELETE' })
|
||||
useSessionState().value = {}
|
||||
useRouter().go(0);
|
||||
}
|
||||
BIN
db.sqlite-shm
Normal file
BIN
db.sqlite-shm
Normal file
Binary file not shown.
BIN
db.sqlite-wal
Normal file
BIN
db.sqlite-wal
Normal file
Binary file not shown.
38
db/schema.ts
38
db/schema.ts
@@ -1,3 +1,4 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { int, text, sqliteTable, type SQLiteTableExtraConfig, primaryKey, blob } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const usersTable = sqliteTable("users", {
|
||||
@@ -5,11 +6,12 @@ export const usersTable = sqliteTable("users", {
|
||||
username: text().notNull().unique(),
|
||||
email: text().notNull().unique(),
|
||||
hash: text().notNull().unique(),
|
||||
state: int().default(0),
|
||||
state: int().notNull().default(0),
|
||||
});
|
||||
|
||||
export const usersDataTable = sqliteTable("users_data", {
|
||||
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const userSessionsTable = sqliteTable("user_sessions", {
|
||||
@@ -17,8 +19,17 @@ export const userSessionsTable = sqliteTable("user_sessions", {
|
||||
user_id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
}, (table): SQLiteTableExtraConfig => {
|
||||
return {
|
||||
pk: primaryKey({ columns: [ table.id, table.user_id ] }),
|
||||
return {
|
||||
pk: primaryKey({ columns: [table.id, table.user_id] }),
|
||||
}
|
||||
});
|
||||
|
||||
export const userPermissionsTable = sqliteTable("user_permissions", {
|
||||
id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
permission: text().notNull(),
|
||||
}, (table): SQLiteTableExtraConfig => {
|
||||
return {
|
||||
pk: primaryKey({ columns: [table.id, table.permission] }),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,4 +41,23 @@ export const explorerContentTable = sqliteTable("explorer_content", {
|
||||
content: blob({ mode: 'buffer' }),
|
||||
navigable: int({ mode: 'boolean' }).default(true),
|
||||
private: int({ mode: 'boolean' }).default(false),
|
||||
});
|
||||
});
|
||||
|
||||
export const usersRelation = relations(usersTable, ({ one, many }) => ({
|
||||
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
|
||||
session: many(userSessionsTable),
|
||||
permission: many(userPermissionsTable),
|
||||
content: many(explorerContentTable),
|
||||
}));
|
||||
export const usersDataRelation = relations(usersDataTable, ({ one }) => ({
|
||||
users: one(usersTable, { fields: [usersDataTable.id], references: [usersTable.id], }),
|
||||
}));
|
||||
export const userSessionsRelation = relations(userSessionsTable, ({ one }) => ({
|
||||
users: one(usersTable, { fields: [userSessionsTable.user_id], references: [usersTable.id], }),
|
||||
}));
|
||||
export const userPermissionsRelation = relations(userPermissionsTable, ({ one }) => ({
|
||||
users: one(usersTable, { fields: [userPermissionsTable.id], references: [usersTable.id], }),
|
||||
}));
|
||||
export const explorerContentRelation = relations(explorerContentTable, ({ one }) => ({
|
||||
users: one(usersTable, { fields: [explorerContentTable.owner], references: [usersTable.id], }),
|
||||
}));
|
||||
@@ -19,6 +19,7 @@ CREATE TABLE `user_sessions` (
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users_data` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`signin` integer NOT NULL,
|
||||
FOREIGN KEY (`id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -27,7 +28,7 @@ CREATE TABLE `users` (
|
||||
`username` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`hash` text NOT NULL,
|
||||
`state` integer DEFAULT 0
|
||||
`state` integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint
|
||||
6
drizzle/0001_sticky_jack_flag.sql
Normal file
6
drizzle/0001_sticky_jack_flag.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE `user_permissions` (
|
||||
`id` integer NOT NULL,
|
||||
`permissions` text NOT NULL,
|
||||
PRIMARY KEY(`id`, `permissions`),
|
||||
FOREIGN KEY (`id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
12
drizzle/0002_messy_solo.sql
Normal file
12
drizzle/0002_messy_solo.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_user_permissions` (
|
||||
`id` integer NOT NULL,
|
||||
`permission` text NOT NULL,
|
||||
PRIMARY KEY(`id`, `permission`),
|
||||
FOREIGN KEY (`id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_user_permissions`("id", "permission") SELECT "id", "permission" FROM `user_permissions`;--> statement-breakpoint
|
||||
DROP TABLE `user_permissions`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_user_permissions` RENAME TO `user_permissions`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "ddf5d5b3-bf1e-4d8d-89cb-230f8e90137a",
|
||||
"id": "f66f1f97-ceb3-46ed-988b-62828fe4a6a6",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"explorer_content": {
|
||||
@@ -141,6 +141,13 @@
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"signin": {
|
||||
"name": "signin",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -198,7 +205,7 @@
|
||||
"name": "state",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
|
||||
298
drizzle/meta/0001_snapshot.json
Normal file
298
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,298 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "854cab71-b937-4f4f-80b0-cbb09c7b5944",
|
||||
"prevId": "f66f1f97-ceb3-46ed-988b-62828fe4a6a6",
|
||||
"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
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"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_permissions_pk": {
|
||||
"columns": [
|
||||
"id",
|
||||
"permissions"
|
||||
],
|
||||
"name": "user_permissions_id_permissions_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": {}
|
||||
}
|
||||
}
|
||||
300
drizzle/meta/0002_snapshot.json
Normal file
300
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,300 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "6da7ff20-0db8-4055-a353-bb0ea2fa5e0b",
|
||||
"prevId": "854cab71-b937-4f4f-80b0-cbb09c7b5944",
|
||||
"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
|
||||
}
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"\"user_permissions\".\"permissions\"": "\"user_permissions\".\"permission\""
|
||||
}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,22 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1730124775172,
|
||||
"tag": "0000_youthful_ma_gnuci",
|
||||
"when": 1730829864576,
|
||||
"tag": "0000_needy_rictor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1730832678255,
|
||||
"tag": "0001_sticky_jack_flag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1730985155814,
|
||||
"tag": "0002_messy_solo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
37
drizzle/relations.ts
Normal file
37
drizzle/relations.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { relations } from "drizzle-orm/relations";
|
||||
import { users, explorerContent, userSessions, usersData, userPermissions } from "./schema";
|
||||
|
||||
export const explorerContentRelations = relations(explorerContent, ({one}) => ({
|
||||
user: one(users, {
|
||||
fields: [explorerContent.owner],
|
||||
references: [users.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const usersRelations = relations(users, ({many}) => ({
|
||||
explorerContents: many(explorerContent),
|
||||
userSessions: many(userSessions),
|
||||
usersData: many(usersData),
|
||||
userPermissions: many(userPermissions),
|
||||
}));
|
||||
|
||||
export const userSessionsRelations = relations(userSessions, ({one}) => ({
|
||||
user: one(users, {
|
||||
fields: [userSessions.userId],
|
||||
references: [users.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const usersDataRelations = relations(usersData, ({one}) => ({
|
||||
user: one(users, {
|
||||
fields: [usersData.id],
|
||||
references: [users.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const userPermissionsRelations = relations(userPermissions, ({one}) => ({
|
||||
user: one(users, {
|
||||
fields: [userPermissions.id],
|
||||
references: [users.id]
|
||||
}),
|
||||
}));
|
||||
57
drizzle/schema.ts
Normal file
57
drizzle/schema.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { sqliteTable, AnySQLiteColumn, foreignKey, text, integer, blob, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
export const explorerContent = sqliteTable("explorer_content", {
|
||||
path: text().primaryKey().notNull(),
|
||||
owner: integer().notNull().references(() => users.id, { onDelete: "cascade", onUpdate: "cascade" } ),
|
||||
title: text().notNull(),
|
||||
type: text().notNull(),
|
||||
content: blob(),
|
||||
navigable: integer().default(true),
|
||||
private: integer().default(false),
|
||||
});
|
||||
|
||||
export const userSessions = sqliteTable("user_sessions", {
|
||||
id: integer().notNull(),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade", onUpdate: "cascade" } ),
|
||||
timestamp: integer().notNull(),
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
pk0: primaryKey({ columns: [table.id, table.userId], name: "user_sessions_id_user_id_pk"})
|
||||
}
|
||||
});
|
||||
|
||||
export const usersData = sqliteTable("users_data", {
|
||||
id: integer().primaryKey().notNull().references(() => users.id, { onDelete: "cascade", onUpdate: "cascade" } ),
|
||||
signin: integer().notNull(),
|
||||
});
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: integer().primaryKey({ autoIncrement: true }).notNull(),
|
||||
username: text().notNull(),
|
||||
email: text().notNull(),
|
||||
hash: text().notNull(),
|
||||
state: integer().default(0).notNull(),
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
hashUnique: uniqueIndex("users_hash_unique").on(table.hash),
|
||||
emailUnique: uniqueIndex("users_email_unique").on(table.email),
|
||||
usernameUnique: uniqueIndex("users_username_unique").on(table.username),
|
||||
}
|
||||
});
|
||||
|
||||
export const userPermissions = sqliteTable("user_permissions", {
|
||||
id: integer().notNull().references(() => users.id, { onDelete: "cascade", onUpdate: "cascade" } ),
|
||||
permissions: text().notNull(),
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
pk0: primaryKey({ columns: [table.id, table.permissions], name: "user_permissions_id_permissions_pk"})
|
||||
}
|
||||
});
|
||||
|
||||
export const drizzleMigrations = sqliteTable("__drizzle_migrations", {
|
||||
});
|
||||
|
||||
22
error.vue
Normal file
22
error.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from '#app'
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
|
||||
const props = defineProps({
|
||||
error: Object as () => NuxtError
|
||||
})
|
||||
|
||||
const handleError = () => clearError({ redirect: '/' })
|
||||
</script>
|
||||
@@ -1,3 +1,75 @@
|
||||
<template>
|
||||
<slot></slot>
|
||||
</template>
|
||||
<CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open">
|
||||
<div class="z-50 md:hidden flex w-full items-center justify-between h-12 border-b border-light-35 dark:border-dark-35">
|
||||
<div class="flex items-center px-2">
|
||||
<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>
|
||||
<NuxtLink class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-md:ps-6" aria-label="Accueil" :to="{ path: '/', force: true }">Accueil</NuxtLink>
|
||||
</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" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</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="flex justify-between items-center max-md:hidden">
|
||||
<NuxtLink class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-md:ps-6" aria-label="Accueil" :to="{ path: '/', force: true }">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
||||
<Avatar src="/logo.light.svg" class="block dark:hidden" />
|
||||
</NuxtLink>
|
||||
<div class="flex gap-4 items-center">
|
||||
<Tooltip message="Changer de theme" side="left"><ThemeSwitch /></Tooltip>
|
||||
<Tooltip :message="loggedIn ? 'Mon profil' : 'Se connecter'" side="right">
|
||||
<NuxtLink class="" :to="{ name: 'user-profile' }">
|
||||
<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" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</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>
|
||||
<p>Copyright Peaceultime - 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
|
||||
const open = ref(true);
|
||||
const { loggedIn } = useUserSession();
|
||||
|
||||
const { data: pages } = await useLazyFetch('/api/navigation', {
|
||||
transform: transform,
|
||||
});
|
||||
|
||||
watch(useRouter().currentRoute, () => {
|
||||
open.value = false;
|
||||
});
|
||||
|
||||
function transform(list: any[]): any[]
|
||||
{
|
||||
return list?.map(e => ({ label: e.title, children: transform(e.children), link: e.path, tag: e.private ? 'private' : e.type }))
|
||||
}
|
||||
</script>
|
||||
8
layouts/login.vue
Normal file
8
layouts/login.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="w-full md:w-auto h-full border-e border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 md:p-8 xl:p-16 flex justify-center items-center">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="hidden md:block flex-auto h-full"></div>
|
||||
</div>
|
||||
</template>
|
||||
36
middleware/auth.global.ts
Normal file
36
middleware/auth.global.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const { loggedIn, fetch, user } = useUserSession();
|
||||
const meta = to.meta;
|
||||
|
||||
await fetch();
|
||||
|
||||
if(!!meta.guestsGoesTo && !loggedIn.value)
|
||||
{
|
||||
return navigateTo(meta.guestsGoesTo);
|
||||
}
|
||||
else if(meta.requireAuth && !loggedIn.value)
|
||||
{
|
||||
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
|
||||
}
|
||||
else if(!!meta.usersGoesTo && loggedIn.value)
|
||||
{
|
||||
return navigateTo(meta.usersGoesTo);
|
||||
}
|
||||
else if(!!meta.validState && (!loggedIn.value || (user.value?.state ?? 0) === 0))
|
||||
{
|
||||
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
|
||||
}
|
||||
else if(!!meta.rights)
|
||||
{
|
||||
if(!user.value)
|
||||
{
|
||||
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
|
||||
}
|
||||
else if(!hasPermissions(user.value.permissions, meta.rights))
|
||||
{
|
||||
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
7
migrate.ts
Normal file
7
migrate.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
const sqlite = new Database("db.sqlite");
|
||||
const db = drizzle(sqlite);
|
||||
await migrate(db, { migrationsFolder: "./drizzle" });
|
||||
@@ -6,7 +6,7 @@ export default defineNuxtConfig({
|
||||
'nuxt-security',
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@vueuse/nuxt',
|
||||
'radix-vue/nuxt'
|
||||
'radix-vue/nuxt',
|
||||
],
|
||||
tailwindcss: {
|
||||
viewer: false,
|
||||
@@ -51,9 +51,11 @@ export default defineNuxtConfig({
|
||||
current: 'currentColor',
|
||||
light: {
|
||||
red: '#e93147',
|
||||
redBack: '#F9C7CD',
|
||||
orange: '#ec7500',
|
||||
yellow: '#e0ac00',
|
||||
green: '#08b94e',
|
||||
greenBack: '#BCECCF',
|
||||
cyan: '#00bfbc',
|
||||
blue: '#086ddd',
|
||||
purple: '#7852ee',
|
||||
@@ -61,21 +63,23 @@ export default defineNuxtConfig({
|
||||
0: "#ffffff",
|
||||
5: "#fcfcfc",
|
||||
10: "#fafafa",
|
||||
20: "#f6f6f6",
|
||||
25: "#e3e3e3",
|
||||
30: "#e0e0e0",
|
||||
35: "#d4d4d4",
|
||||
20: "#f7f7f7",
|
||||
25: "#e4e4e4",
|
||||
30: "#dfdfdf",
|
||||
35: "#d2d2d2",
|
||||
40: "#bdbdbd",
|
||||
50: "#ababab",
|
||||
60: "#707070",
|
||||
70: "#5c5c5c",
|
||||
100: "#222222",
|
||||
100: "#202020",
|
||||
},
|
||||
dark: {
|
||||
red: '#fb464c',
|
||||
redBack: '#5A292B',
|
||||
orange: '#e9973f',
|
||||
yellow: '#e0de71',
|
||||
green: '#44cf6e',
|
||||
greenBack: '#284E34',
|
||||
cyan: '#53dfdd',
|
||||
blue: '#027aff',
|
||||
purple: '#a882ff',
|
||||
@@ -104,19 +108,31 @@ export default defineNuxtConfig({
|
||||
app: {
|
||||
pageTransition: false,
|
||||
layoutTransition: false
|
||||
}/*,
|
||||
},
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
pathPrefix: false,
|
||||
},
|
||||
]*/,
|
||||
],
|
||||
nitro: {
|
||||
experimental: {
|
||||
tasks: true,
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
session: {
|
||||
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
|
||||
},
|
||||
database: 'db.sqlite'
|
||||
},
|
||||
security: {
|
||||
rateLimiter: false,
|
||||
headers: {
|
||||
contentSecurityPolicy: {
|
||||
"img-src": "'self' data: blob:"
|
||||
}
|
||||
}
|
||||
},
|
||||
xssValidator: false,
|
||||
},
|
||||
})
|
||||
12
package.json
12
package.json
@@ -2,22 +2,28 @@
|
||||
"name": "d-any",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "bun i && bunx nuxi cleanup",
|
||||
"dev": "bunx --bun nuxi dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "^4.1.2",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||
"@vueuse/nuxt": "^11.1.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"drizzle-orm": "^0.35.3",
|
||||
"nuxt": "^3.13.2",
|
||||
"nuxt": "^3.14.159",
|
||||
"nuxt-security": "^2.0.0",
|
||||
"radix-vue": "^1.9.8",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest"
|
||||
"vue-router": "latest",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.12",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"bun-types": "^1.1.33",
|
||||
"bun-types": "^1.1.34",
|
||||
"drizzle-kit": "^0.26.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
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;
|
||||
|
||||
try
|
||||
{
|
||||
data.value = await $fetch(`/api/admin/jobs/${job.value}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
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, content: error.value, type: 'error', timer: true, });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
Administration
|
||||
<Head>
|
||||
<Title>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>
|
||||
</template>
|
||||
22
pages/editor.vue
Normal file
22
pages/editor.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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,3 +1,49 @@
|
||||
<template>
|
||||
Current path: {{ $route.params.path }}
|
||||
</template>
|
||||
<div v-if="status === 'pending'" class="flex">
|
||||
<Head>
|
||||
<Title>d[any] - Chargement</Title>
|
||||
</Head>
|
||||
<Loading />
|
||||
</div>
|
||||
<div class="flex flex-1 justify-start items-start" v-else-if="page">
|
||||
<Head>
|
||||
<Title>d[any] - {{ page.title }}</Title>
|
||||
</Head>
|
||||
<template v-if="page.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>
|
||||
</div>
|
||||
<Markdown :content="page.content" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page.type === 'canvas'">
|
||||
<Canvas :canvas="JSON.parse(page.content)" />
|
||||
</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'">
|
||||
<Head>
|
||||
<Title>d[any] - Erreur</Title>
|
||||
</Head>
|
||||
<span>{{ error?.message }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Head>
|
||||
<Title>d[any] - Erreur</Title>
|
||||
</Head>
|
||||
<div><ProseH2>Impossible d'afficher le contenu demandé</ProseH2></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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);
|
||||
</script>
|
||||
88
pages/explore/edit/[...path].vue
Normal file
88
pages/explore/edit/[...path].vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<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>
|
||||
@@ -1,32 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
const open = ref(false), username = ref(""), price = ref(750), disabled = ref(false);
|
||||
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>Accueil</Title>
|
||||
<Title>d[any] - Accueil</Title>
|
||||
</Head>
|
||||
<div class="h-100 w-100 flex flex-1 flex-col justify-center items-center">
|
||||
<div class="w-1/2 flex flex-1 flex-col justify-center items-center">
|
||||
<Switch label="Test" v-model="disabled" />
|
||||
<SliderInput :disabled="disabled" :label="`Prix: ${price.toFixed(2)}€`" :min="0" :max="1500" :step="0.25"
|
||||
v-model="price" />
|
||||
<TextInput label="Saisir un pseudonyme" :disabled="disabled" v-model="username" />
|
||||
<NumberPicker label="Age" :disabled="disabled" />
|
||||
<Separator decorative orientation="horizontal" class="h-px w-96 my-2 bg-light-30 dark:bg-dark-30" />
|
||||
<HoverCard>
|
||||
<span>Texte</span>
|
||||
<template v-slot:content>
|
||||
<div>Test de HoverCard</div>
|
||||
</template>
|
||||
</HoverCard>
|
||||
<Dialog label="Open" title="Titre" description="Description courte de la popup">
|
||||
<div class="flex flex-col items-start">
|
||||
<Separator decorative orientation="horizontal" class="h-px w-96 my-2 bg-light-30 dark:bg-dark-30" />
|
||||
<span>Contenu special</span>
|
||||
<Switch label="Theme sombre ?" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div class="h-full w-full flex flex-1 flex-col justify-center items-center">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden w-48 h-48" />
|
||||
<Avatar src="/logo.light.svg" class="block dark:hidden w-48 h-48" />
|
||||
<h1 class="text-5xl font-thin font-mono">Bienvenue</h1>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,3 +1,33 @@
|
||||
<template>
|
||||
|
||||
<div class="flex flex-col max-w-[1200px] p-16">
|
||||
<ProseH3>Mentions Légales</ProseH3>
|
||||
<ProseH4>Collecte et Traitement des Données Personnelles</ProseH4>
|
||||
Ce site collecte des données personnelles durant l'inscription et des données anonymes durant la navigation sur
|
||||
le site dans un but de collecte statistiques.<br />
|
||||
|
||||
Conformément à la réglementation en vigueur, vous disposez d'un droit d'accès, de rectification et de
|
||||
suppression de vos données personnelles. <br />
|
||||
|
||||
Pour exercer ces droits, vous pouvez vous rendre dans votre profil et selectionner l'option "Supprimer mon
|
||||
compte" qui garanti une suppression de l'intégralité de vos données personnelles.
|
||||
|
||||
<ProseH4>Utilisation des Cookies</ProseH4>
|
||||
Ce site utilise des cookies uniquement pour maintenir la connexion des utilisateurs et faciliter leur navigation
|
||||
lors de chaque visite. Aucune information de suivi ou de profilage n'est réalisée. Ces cookies sont essentiels
|
||||
au fonctionnement du site et ne nécessitent pas de consentement préalable. <br />
|
||||
|
||||
Vous pouvez gérer les cookies en configurant les paramètres de votre navigateur, mais la désactivation de ces
|
||||
cookies pourrait affecter votre expérience de navigation.
|
||||
|
||||
<ProseH4>Limitation de Responsabilité</ProseH4>
|
||||
Les informations publiées sur ce site sont fournies à titre indicatif et peuvent contenir des erreurs. <br />
|
||||
L'éditeur décline toute responsabilité quant à l'usage qui pourrait être fait de ces informations.
|
||||
|
||||
<ProseH4>Propriété Intellectuelle</ProseH4>
|
||||
Tous les contenus présents sur ce site (textes, images, logos, etc.) sont protégés par les lois en vigueur
|
||||
sur la propriété intellectuelle. Toute reproduction ou utilisation de ces contenus sans autorisation préalable
|
||||
est interdite. <br /><br />
|
||||
|
||||
<span class="text-light-60 dark:text-dark-60 text-lg">© Copyright Peaceultime - 2024</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
Edit your profile
|
||||
</template>
|
||||
@@ -1,3 +1,95 @@
|
||||
<template>
|
||||
Login
|
||||
</template>
|
||||
<Head>
|
||||
<Title>Connexion</Title>
|
||||
</Head>
|
||||
<div class="flex flex-1 flex-col justify-center items-center">
|
||||
<div class="flex gap-8 items-center">
|
||||
<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>
|
||||
<ProseH4>Connexion</ProseH4>
|
||||
</div>
|
||||
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">
|
||||
<TextInput type="text" label="Utilisateur ou email" autocomplete="username" v-model="state.usernameOrEmail"/>
|
||||
<TextInput type="password" label="Mot de passe" autocomplete="current-password" v-model="state.password"/>
|
||||
<Button class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Se connecter</Button>
|
||||
<NuxtLink class="mt-4 text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-register' }">Pas de compte ?</NuxtLink>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ZodError } from 'zod';
|
||||
import { schema, type Login } from '~/schemas/login';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'login',
|
||||
usersGoesTo: '/user/profile',
|
||||
});
|
||||
|
||||
const { add: addToast, clear: clearToasts } = useToast();
|
||||
|
||||
const state = reactive<Login>({
|
||||
usernameOrEmail: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const { data: result, status, error, refresh } = await useFetch('/api/auth/login', {
|
||||
body: state,
|
||||
immediate: false,
|
||||
method: 'POST',
|
||||
watch: false,
|
||||
ignoreResponseError: true,
|
||||
})
|
||||
|
||||
const toastMessage = ref('');
|
||||
|
||||
async function submit()
|
||||
{
|
||||
if(state.usernameOrEmail === "")
|
||||
return addToast({ content: 'Veuillez saisir un nom d\'utilisateur ou un email', timer: true, duration: 10000 });
|
||||
if(state.password === "")
|
||||
return addToast({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 });
|
||||
|
||||
const data = schema.safeParse(state);
|
||||
|
||||
if(data.success)
|
||||
{
|
||||
await refresh();
|
||||
|
||||
const login = result.value;
|
||||
if(!login || !login.success)
|
||||
{
|
||||
handleErrors(login?.error ?? error.value!);
|
||||
}
|
||||
else if(status.value === 'success' && login.success)
|
||||
{
|
||||
clearToasts();
|
||||
addToast({ duration: 10000, content: 'Vous êtes maintenant connecté', timer: true, type: 'success' });
|
||||
await navigateTo('/user/profile');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
handleErrors(data.error);
|
||||
}
|
||||
}
|
||||
function handleErrors(error: Error | ZodError)
|
||||
{
|
||||
if(!error)
|
||||
return;
|
||||
|
||||
status.value = 'error';
|
||||
|
||||
if(error.hasOwnProperty('issues'))
|
||||
{
|
||||
for(const err of (error as ZodError).issues)
|
||||
{
|
||||
return addToast({ content: err.message, timer: true, duration: 10000, type: 'error' });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return addToast({ content: error?.message ?? 'Une erreur est survenue', timer: true, duration: 10000, type: 'error' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
82
pages/user/profile.vue
Normal file
82
pages/user/profile.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
guestsGoesTo: '/user/login',
|
||||
})
|
||||
let { user, clear } = useUserSession();
|
||||
|
||||
async function deleteUser()
|
||||
{
|
||||
await $fetch(`/api/users/${user.value?.id}`, {
|
||||
method: 'delete'
|
||||
});
|
||||
clear();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head>
|
||||
<Title>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">
|
||||
<div class="flex gap-4">
|
||||
<Avatar icon="radix-icons:person" :src="`/users/${user?.id}.medium.jpg`" class="w-32 h-32" />
|
||||
<div class="flex flex-col items-start">
|
||||
<ProseH5>{{ user.username }}</ProseH5>
|
||||
<ProseH5>{{ user.email }}</ProseH5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-light-red dark:border-dark-red bg-light-redBack dark:bg-dark-redBack text-light-red dark:text-dark-red py-1 px-3 flex items-center justify-between flex-col md:flex-row"
|
||||
v-if="user.state === 0">
|
||||
<HoverCard>
|
||||
<template v-slot:default>Votre adresse mail n'as pas encore été validée. </template>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col self-center flex-1 gap-4">
|
||||
<Button @click="async () => await clear()">Se deconnecter</Button>
|
||||
<Button disabled><Tooltip message="En cours de développement">Modifier mon profil</Tooltip></Button>
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger asChild><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">Supprimer
|
||||
mon compte</Button></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">Suppression de compte
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="text-base pb-2">Supprimer votre compte va supprimer toutes vos
|
||||
données personnelles de notre base de données, incluant vos commentaires et vos
|
||||
contributions. <br />
|
||||
Êtes vous sûr de vouloir supprimer votre compte ?
|
||||
</AlertDialogDescription>
|
||||
<div class="flex flex-1 justify-end gap-4">
|
||||
<AlertDialogCancel asChild><Button>Annuler</Button></AlertDialogCancel>
|
||||
<AlertDialogAction asChild><Button @click="() => deleteUser()" 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</Button></AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</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,3 +1,120 @@
|
||||
<template>
|
||||
Register
|
||||
</template>
|
||||
<Head>
|
||||
<Title>Inscription</Title>
|
||||
</Head>
|
||||
<div class="flex flex-1 flex-col justify-center items-center">
|
||||
<div class="flex gap-8 items-center">
|
||||
<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>
|
||||
<ProseH4>Inscription</ProseH4>
|
||||
</div>
|
||||
<form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0">
|
||||
<TextInput type="text" label="Nom d'utilisateur" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
|
||||
<TextInput type="email" label="Email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/>
|
||||
<TextInput type="password" label="Mot de passe" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/>
|
||||
<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>
|
||||
<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>
|
||||
<span class="mt-4 order-10 flex justify-center items-center gap-4 col-span-2 md:col-span-1 m-auto">Vous avez déjà un compte ?<NuxtLink class="text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-login' }">Se connecter</NuxtLink></span>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ZodError } from 'zod';
|
||||
import { schema, type Registration } from '~/schemas/registration';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'login',
|
||||
usersGoesTo: '/user/profile',
|
||||
});
|
||||
|
||||
const state = reactive<Registration>({
|
||||
username: '',
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
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 checkedDigit = computed(() => /[0-9]/.test(state.password));
|
||||
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));
|
||||
|
||||
const { data: result, status, error, refresh } = await useFetch('/api/auth/register', {
|
||||
body: state,
|
||||
immediate: false,
|
||||
method: 'POST',
|
||||
watch: false,
|
||||
ignoreResponseError: true,
|
||||
})
|
||||
|
||||
async function submit()
|
||||
{
|
||||
if(state.username === '')
|
||||
return addToast({ content: 'Veuillez saisir un nom d\'utilisateur', timer: true, duration: 10000 });
|
||||
if(state.email === '')
|
||||
return addToast({ content: 'Veuillez saisir une adresse mail', timer: true, duration: 10000 });
|
||||
if(state.password === "")
|
||||
return addToast({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 });
|
||||
if(state.password !== confirmPassword.value)
|
||||
return addToast({ content: 'Les deux mots de passe saisis ne correspondent pas', timer: true, duration: 10000 });
|
||||
|
||||
const data = schema.safeParse(state);
|
||||
|
||||
if(data.success)
|
||||
{
|
||||
await refresh();
|
||||
|
||||
const login = result.value;
|
||||
if(!login || !login.success)
|
||||
{
|
||||
handleErrors(login?.error ?? error.value!);
|
||||
}
|
||||
else if(status.value === 'success' && login.success)
|
||||
{
|
||||
clearToasts();
|
||||
addToast({ duration: 10000, content: 'Vous avez été enregistré. Pensez à valider votre adresse mail.', timer: true, type: 'success' });
|
||||
await navigateTo('/user/profile');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
handleErrors(data.error);
|
||||
}
|
||||
}
|
||||
function handleErrors(error: Error | ZodError)
|
||||
{
|
||||
if(!error)
|
||||
return;
|
||||
|
||||
status.value = 'error';
|
||||
|
||||
if(error.hasOwnProperty('issues'))
|
||||
{
|
||||
for(const err of (error as ZodError).issues)
|
||||
{
|
||||
return addToast({ content: err.message, timer: true, duration: 10000, type: 'error' });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return addToast({ content: error?.message ?? 'Une erreur est survenue', timer: true, duration: 10000, type: 'error' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 20 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user