You've already forked obsidian-visualiser
Compare commits
19 Commits
4c8fb0ff77
...
rework
| Author | SHA1 | Date | |
|---|---|---|---|
| 60f1fbb4aa | |||
| 42658558c5 | |||
| 057efb848c | |||
| 721e7ff3db | |||
| 41951d7603 | |||
| a392841012 | |||
| b3fae0b5db | |||
| 1af78e5ab7 | |||
| 83ddaf19d4 | |||
| e8b521f122 | |||
| 0105a6aaea | |||
| 633231f821 | |||
| 5ce2d3e236 | |||
| 8a19448a38 | |||
| bd32d176b1 | |||
| cbce979aa9 | |||
| f80c6d5326 | |||
| a5a9086eb7 | |||
| f37c3e4cc9 |
37
app.vue
37
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>
|
||||
<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>
|
||||
</ToastProvider>
|
||||
</TooltipProvider>
|
||||
</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>
|
||||
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>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<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">
|
||||
<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" />
|
||||
</SwitchRoot>
|
||||
</Label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { label, disabled } = defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}>();
|
||||
const model = defineModel<boolean>();
|
||||
</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>
|
||||
39
components/base/Dialog.vue
Normal file
39
components/base/Dialog.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<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">
|
||||
<DialogTitle v-if="!!title" class="text-3xl font-light relative -top-2">{{ title }}</DialogTitle>
|
||||
<DialogDescription v-if="!!description" class="text-base pb-2">{{ description }}</DialogDescription>
|
||||
<slot />
|
||||
<DialogClose v-if="iconClose" class="text-light-100 dark:text-dark-100 absolute top-2 right-2 inline-flex h-6 w-6 appearance-none items-center justify-center outline-none text-xl" aria-label="Close">
|
||||
<span aria-hidden>×</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
<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">
|
||||
<AlertDialogTitle v-if="!!title" class="text-3xl font-light relative -top-2">{{ title }}</AlertDialogTitle>
|
||||
<AlertDialogDescription v-if="!!description" class="text-base pb-2">{{ description }}</AlertDialogDescription>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { label, title, description, priority = false, disabled = false, iconClose = true } = defineProps<{
|
||||
label?: string
|
||||
title?: string
|
||||
description?: string
|
||||
priority?: boolean
|
||||
disabled?: boolean
|
||||
iconClose?: boolean
|
||||
}>();
|
||||
const model = defineModel();
|
||||
</script>
|
||||
21
components/base/HoverCard.vue
Normal file
21
components/base/HoverCard.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<HoverCardRoot :open-delay="delay">
|
||||
<HoverCardTrigger class="inline-block cursor-help outline-none">
|
||||
<slot></slot>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal v-if="!disabled">
|
||||
<HoverCardContent :class="$attrs.class" :side="side" class="data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 p-5 data-[state=open]:transition-all text-light-100 dark:text-dark-100" >
|
||||
<slot name="content"></slot>
|
||||
<HoverCardArrow class="fill-light-35 dark:fill-dark-35" />
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCardRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { delay = 500, disabled = false, side = 'bottom' } = defineProps<{
|
||||
delay?: number
|
||||
disabled?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>();
|
||||
</script>
|
||||
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>
|
||||
23
components/base/NumberPicker.vue
Normal file
23
components/base/NumberPicker.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<Label class="my-2 flex">{{ label }}
|
||||
<NumberFieldRoot :min="min" :max="max" v-model="model" :disabled="disabled" :step="step" class="ms-4 flex justify-center border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20
|
||||
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 hover:border-light-50 dark:hover:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
|
||||
<NumberFieldDecrement class="data-[disabled]:opacity-50 px-1"><Icon icon="radix-icons:minus" :inline="true" class="w-6 text-light-100 dark:text-dark-100 opacity-100" /></NumberFieldDecrement>
|
||||
<NumberFieldInput class="text-sm tabular-nums w-20 appearance-none bg-transparent px-2 py-1 outline-none caret-light-50 dark:caret-dark-50" />
|
||||
<NumberFieldIncrement class="data-[disabled]:opacity-50 px-1"><Icon icon="radix-icons:plus" :inline="true" class="w-6 text-light-100 dark:text-dark-100 opacity-100" /></NumberFieldIncrement>
|
||||
</NumberFieldRoot>
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
|
||||
const { min = 0, max = 100, disabled = false, step = 1, label } = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
disabled?: boolean
|
||||
step?: number
|
||||
label?: string
|
||||
}>();
|
||||
const model = defineModel<number>();
|
||||
</script>
|
||||
20
components/base/PinPicker.vue
Normal file
20
components/base/PinPicker.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<Label class="my-2">{{ label }}
|
||||
<PinInputRoot :disabled="disabled" :default-value="model?.split('')" @update:model-value="(v) => model = v.join('')" @complete="() => emit('complete')" class="flex gap-2 items-center mt-1">
|
||||
<PinInputInput :type="hidden ? 'password' : undefined" v-for="(id, index) in amount" :key="id" :index="index" class="border border-light-35 dark:border-dark-35 w-10 h-10 outline-none
|
||||
bg-light-20 dark:bg-dark-20 text-center text-light-100 dark:text-dark-100 placeholder:text-light-60 dark:placeholder:text-dark-60 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20
|
||||
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 caret-light-50 dark:caret-dark-50" />
|
||||
</PinInputRoot>
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { hidden = false, amount, label, disabled = false } = defineProps<{
|
||||
hidden?: boolean
|
||||
label?: string
|
||||
amount: number
|
||||
disabled?: boolean
|
||||
}>();
|
||||
const model = defineModel<string>();
|
||||
const emit = defineEmits(['complete']);
|
||||
</script>
|
||||
13
components/base/Progress.vue
Normal file
13
components/base/Progress.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ProgressRoot :max="max" v-model="model" class="my-2 relative overflow-hidden bg-light-25 dark:bg-dark-25 w-48 h-3 data-[shape=thin]:h-1 data-[shape=large]:h-6" :data-shape="shape" style="transform: translateZ(0)" >
|
||||
<ProgressIndicator class="bg-light-50 dark:bg-dark-50 h-full transition-[width] duration-[660ms] ease-[cubic-bezier(0.65, 0, 0.35, 1)]" :style="`width: ${((model ?? 0) / max) * 100}%`" />
|
||||
</ProgressRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { max = 100, shape = 'normal' } = defineProps<{
|
||||
max?: number
|
||||
shape?: 'thin' | 'normal' | 'large'
|
||||
}>();
|
||||
const model = defineModel<number>();
|
||||
</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>
|
||||
25
components/base/Switch.vue
Normal file
25
components/base/Switch.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<Label class="flex justify-center items-center my-2">{{ label }}
|
||||
<SwitchRoot v-model:checked="model" :disabled="disabled"
|
||||
class="group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none
|
||||
data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
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">
|
||||
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">{{ 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>
|
||||
|
||||
21
components/base/TimerProgress.vue
Normal file
21
components/base/TimerProgress.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<ProgressRoot class="my-2 relative overflow-hidden bg-light-25 dark:bg-dark-25 w-48 h-3 data-[shape=thin]:h-1 data-[shape=large]:h-6" :data-shape="shape" style="transform: translateZ(0)" >
|
||||
<ProgressIndicator class="bg-light-50 dark:bg-dark-50 h-full w-0 transition-[width] ease-linear" :style="`transition-duration: ${delay}ms; width: ${progress ? 100 : 0}%`" />
|
||||
</ProgressRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { delay = 1500, decreasing = false, shape = 'normal' } = defineProps<{
|
||||
delay?: number
|
||||
decreasing?: boolean
|
||||
shape?: 'thin' | 'normal' | 'large'
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['finish']);
|
||||
|
||||
const progress = ref(false);
|
||||
nextTick(() => {
|
||||
progress.value = true;
|
||||
setTimeout(emit, delay, 'finish');
|
||||
});
|
||||
</script>
|
||||
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>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<TooltipRoot :delay-duration="delay">
|
||||
<TooltipRoot :delay-duration="delay" :disabled="disabled">
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
@@ -15,7 +15,8 @@
|
||||
<script setup lang="ts">
|
||||
const { message, delay = 300, side } = defineProps<{
|
||||
message: string
|
||||
delay?: number
|
||||
delay?: number,
|
||||
disabled?: boolean
|
||||
side?: 'left' | 'right' | 'top' | 'bottom'
|
||||
}>();
|
||||
</script>
|
||||
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;");
|
||||
|
||||
return db;
|
||||
instance.run("PRAGMA journal_mode = WAL;");
|
||||
instance.run("PRAGMA foreign_keys = true;");
|
||||
}
|
||||
|
||||
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.
32
db/schema.ts
32
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", {
|
||||
@@ -22,6 +24,15 @@ export const userSessionsTable = sqliteTable("user_sessions", {
|
||||
}
|
||||
});
|
||||
|
||||
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] }),
|
||||
}
|
||||
});
|
||||
|
||||
export const explorerContentTable = sqliteTable("explorer_content", {
|
||||
path: text().primaryKey(),
|
||||
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
@@ -31,3 +42,22 @@ export const explorerContentTable = sqliteTable("explorer_content", {
|
||||
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>
|
||||
<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,
|
||||
@@ -16,15 +16,46 @@ export default defineNuxtConfig({
|
||||
boxShadow: {
|
||||
raw: '0 0 0 2px var(--tw-shadow-color)'
|
||||
},
|
||||
keyframes: {
|
||||
slideDownAndFade: {
|
||||
from: { opacity: '0', transform: 'translateY(-2px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
slideLeftAndFade: {
|
||||
from: { opacity: '0', transform: 'translateX(2px)' },
|
||||
to: { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
slideUpAndFade: {
|
||||
from: { opacity: '0', transform: 'translateY(2px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
slideRightAndFade: {
|
||||
from: { opacity: '0', transform: 'translateX(-2px)' },
|
||||
to: { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
contentShow: {
|
||||
from: { opacity: '0', transform: 'translate(-50%, -48%) scale(0.96)' },
|
||||
to: { opacity: '1', transform: 'translate(-50%, -50%) scale(1)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
slideDownAndFade: 'slideDownAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
slideLeftAndFade: 'slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
slideUpAndFade: 'slideUpAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
slideRightAndFade: 'slideRightAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
contentShow: 'contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
},
|
||||
},
|
||||
colors: {
|
||||
transparent: 'transparent',
|
||||
current: 'currentColor',
|
||||
light: {
|
||||
red: '#e93147',
|
||||
redBack: '#F9C7CD',
|
||||
orange: '#ec7500',
|
||||
yellow: '#e0ac00',
|
||||
green: '#08b94e',
|
||||
greenBack: '#BCECCF',
|
||||
cyan: '#00bfbc',
|
||||
blue: '#086ddd',
|
||||
purple: '#7852ee',
|
||||
@@ -32,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',
|
||||
@@ -75,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 }}
|
||||
<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,24 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
const open = ref(false), username = ref(""), price = ref(750);
|
||||
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" />
|
||||
<SliderInput :label="`Prix: ${price.toFixed(2)}€`" :min="0" :max="1500" :step="0.25"
|
||||
v-model="price" />
|
||||
<TextInput label="Saisir un pseudonyme" v-model="username" />
|
||||
<Separator decorative orientation="horizontal" class="h-px w-96 my-2 bg-light-30 dark:bg-dark-30" />
|
||||
<Tooltip message="Vas y, ajoute stp" side="top"><button @click="open = !!username && !open"
|
||||
class="text-3xl font-extralight tracking-wide text-light-60 dark:text-dark-60">Ajouter</button>
|
||||
</Tooltip>
|
||||
<Toast v-model="open" :title="`Bienvenue ${username}`" content="Vous êtes maintenant connecté"
|
||||
:duration="5000" />
|
||||
</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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user