Compare commits

...

23 Commits

Author SHA1 Message Date
Peaceultime 42658558c5 Add user deletion, ProseA hover cards, Canvas 2024-11-10 22:29:59 +01:00
Peaceultime 057efb848c Several fixes to CSS, better responsive overall, improved security and error page 2024-11-10 17:44:42 +01:00
Peaceultime 721e7ff3db Add sync, Tree, Markdown, content editor. 2024-11-10 15:41:47 +01:00
Peaceultime 41951d7603 Add permissions 2024-11-07 14:26:57 +01:00
Peaceultime a392841012 Fix sessions, start profile UI and add middleware 2024-11-06 17:38:15 +01:00
Peaceultime b3fae0b5db Setup global toaster and finalize login/registration page 2024-11-06 14:29:32 +01:00
Peaceultime 1af78e5ab7 Fix login, registration and made the first database version. 2024-11-05 19:51:56 +01:00
Peaceultime 83ddaf19d4 Starting to put back the server part. Currently the registration and login are almost ready. 2024-11-05 18:09:42 +01:00
Peaceultime e8b521f122 Starting to rework the NavBar 2024-11-04 17:35:22 +01:00
Peaceultime 0105a6aaea Adding prestyled base tags and testing admin dashboard 2024-11-04 16:34:11 +01:00
Peaceultime 633231f821 Change a few colors and setup the theme switch to test the light theme 2024-11-04 13:49:49 +01:00
Peaceultime 5ce2d3e236 Move all base components to an isolated folder 2024-11-04 13:34:23 +01:00
Peaceultime 8a19448a38 Add Loading to Avatar, add timer progress to Toast 2024-10-31 14:23:44 +01:00
Peaceultime bd32d176b1 Add Collapsible, Avatar and Loading 2024-10-31 13:59:29 +01:00
Peaceultime cbce979aa9 Add Button and Dialog (and AlertDialog which is embedded in the Dialog through the priority attribute) 2024-10-30 17:47:14 +01:00
Peaceultime f80c6d5326 Add HoverCard 2024-10-30 14:11:04 +01:00
Peaceultime a5a9086eb7 Add PinPicker and NumberPicker 2024-10-30 13:24:21 +01:00
Peaceultime f37c3e4cc9 Add Progress and TimerProgress 2024-10-30 11:57:48 +01:00
Peaceultime 4c8fb0ff77 Add RadioGroup and style for disabled fields 2024-10-30 00:07:49 +01:00
Peaceultime f4f4be6b27 Progressing on the components with the Slider and the Switch 2024-10-29 17:36:15 +01:00
Peaceultime 97f8ca499a Progressing on the basic components implementation 2024-10-29 14:04:05 +01:00
Peaceultime 1b2472bc1a First reworks 2024-10-28 17:49:46 +01:00
Peaceultime fef8c092a9 Small fixes 2024-10-28 09:21:44 +01:00
155 changed files with 3032 additions and 2351 deletions

3
.gitignore vendored
View File

@ -5,7 +5,6 @@
.nitro
.cache
dist
content
# Node dependencies
node_modules
@ -23,5 +22,3 @@ logs
.env
.env.*
!.env.example
db.sqlite-*

31
.vscode/launch.json vendored
View File

@ -1,31 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "client: chrome",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
},
{
"type": "node",
"request": "launch",
"name": "server: nuxt",
"outputCapture": "std",
"program": "${workspaceFolder}/node_modules/nuxi/bin/nuxi.mjs",
"args": [
"dev"
],
}
],
"compounds": [
{
"name": "fullstack: nuxt",
"configurations": [
"server: nuxt",
"client: chrome"
]
}
]
}

View File

48
app.vue
View File

@ -1,3 +1,23 @@
<template>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
<NuxtRouteAnnouncer/>
<TooltipProvider>
<NuxtLayout>
<div class="xl:ps-12 xl:pe-12 ps-6 pe-4 flex flex-1 justify-center overflow-auto max-h-full relative">
<NuxtPage></NuxtPage>
</div>
</NuxtLayout>
<Toaster v-model="list" />
</TooltipProvider>
</div>
</template>
<script setup lang="ts">
provideToaster();
const { list } = useToast();
</script>
<style>
::-webkit-scrollbar {
width: 12px;
@ -18,32 +38,4 @@
@apply bg-light-50;
@apply dark:bg-dark-50;
}
.loading {
@apply w-full;
@apply h-full;
@apply flex;
@apply items-center;
@apply justify-center;
@apply before:border-4;
@apply before:border-accent-purple;
@apply before:border-t-transparent;
@apply before:rounded-full;
@apply before:w-8;
@apply before:h-8;
@apply before:animate-spin;
}
</style>
<script setup lang="ts">
const page = ref();
</script>
<template>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
<NuxtRouteAnnouncer></NuxtRouteAnnouncer>
<NuxtLayout>
<NuxtPage ref="page"></NuxtPage>
</NuxtLayout>
</div>
</template>

View File

@ -1,7 +0,0 @@
### Librairies, framework et outils libres utilisés:
- [Vuejs](https://vuejs.org/) - MIT License - Copyright (c) 2018-present, Yuxi (Evan) You and Vue contributors
- [vue-router](https://router.vuejs.org/) - MIT License - Copyright (c) 2019-present Eduardo San Martin Morote
- [Nuxt](https://nuxt.com/) - MIT License - Copyright (c) 2016-present - Nuxt Team
- [nuxt-auth-utils](https://github.com/atinux/nuxt-auth-utils) - MIT License - Copyright (c) 2023 Sébastien Chopin
Le logo a été créé grace aux icones de [Game Icons](https://game-icons.net).

BIN
bun.lockb

Binary file not shown.

View File

@ -1,130 +0,0 @@
<style>
.editor
{
white-space: pre-line;
overflow: auto;
outline: none;
box-shadow: none !important;
}
</style>
<template>
<div class="editor">
<template
v-if="model && model.length > 0">
<MarkdownRenderer
v-if="node"
:key="key"
:node="node"
:proses="{
'a': LiveA,
'h1': LiveH1,
'h2': LiveH2,
'h3': LiveH3,
'h4': LiveH4,
'h5': LiveH5,
'h6': LiveH6,
'blockquote': LiveBlockquote,
}"
></MarkdownRenderer>
</template>
</div>
</template>
<script setup lang="ts">
import LiveA from "~/components/prose/live/LiveA.vue";
import LiveH1 from "~/components/prose/live/LiveH1.vue";
import LiveH2 from "~/components/prose/live/LiveH2.vue";
import LiveH3 from "~/components/prose/live/LiveH3.vue";
import LiveH4 from "~/components/prose/live/LiveH4.vue";
import LiveH5 from "~/components/prose/live/LiveH5.vue";
import LiveH6 from "~/components/prose/live/LiveH6.vue";
import LiveBlockquote from "~/components/prose/ProseBlockquote.vue";
import { hash } from 'ohash'
import { watch, computed } from 'vue'
import type { Root, Node } from 'hast';
/* import { diffLines as diff } from 'diff'; */
const model = defineModel<string>();
const parser = useMarkdown();
const key = computed(() => hash(model.value));
const node = ref<Root>(), changes = ref();
watch(model, update);
update(model.value, "");
async function update(value: string | undefined, old: string | undefined) {
if(value && old)
{
if(node.value)
{
/* const differences = diff(old, value, {
newlineIsToken: true,
});
let removeStart = 0, removeEnd = 0; //Character count
let addStart = 0, addEnd = 0; //Character count
const needAdd = differences.find(e => e.added) !== undefined;
const needRemove = differences.find(e => e.removed) !== undefined;
for(const difference of differences)
{
if(!difference.added && !difference.removed)
{
removeStart += difference.value.length;
addStart += difference.value.length;
}
else if(difference.added)
{
addEnd = addStart + difference.value.length;
}
else if(difference.removed)
{
removeEnd = removeStart + difference.value.length;
}
if((!needAdd || addEnd !== 0) && (!needRemove || removeEnd !== 0))
break;
}
const oldNodes = getNodes(node.value.children, removeStart - 1, removeEnd + 1);
let newNodes;
if(oldNodes.length === 0)
{
node.value = parser(value);
}
else
{
const newStart = oldNodes[0].position?.start.offset;
const newEnd = oldNodes[oldNodes.length - 1].position?.end.offset;
const lengthDiff = value.length - old.length;
newNodes = parser(value.substring(newStart ?? 0, (newEnd ?? 0) + lengthDiff));
const root = node.value;
node.value = parser(value);
} */
node.value = parser(value);
}
else
{
node.value = parser(value);
}
}
else if(value)
{
node.value = parser(value);
}
}
function getNodes(nodes: Node[], start: number, end: number)
{
return nodes.filter(e => (e.position?.start.offset ?? 0) <= end && (e.position?.end.offset ?? 0) >= start);
}
</script>

85
components/Editor.vue Normal file
View 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>

23
components/Markdown.vue Normal file
View File

@ -0,0 +1,23 @@
<template>
<template v-if="content && content.length > 0">
<Suspense :timeout="0">
<MarkdownRenderer #default :key="key" v-if="node" :node="node"></MarkdownRenderer>
<template #fallback><Loading /></template>
</Suspense>
</template>
</template>
<script setup lang="ts">
import { hash } from 'ohash'
const { content } = defineProps({
content: {
type: String,
required: true,
}
})
const parser = useMarkdown();
const key = computed(() => hash(content));
const node = computed(() => content ? parser(content) : undefined);
</script>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,30 @@
<template>
<RadioGroupRoot :disabled="disabled" v-model="model" class="flex flex-col gap-2 p-2">
<Label v-for="option in options" class="flex items-center gap-2">
<RadioGroupItem :disabled="(option as RadioOption).disabled ?? false"
:value="(option as RadioOption).value ?? option"
class="border border-light-60 dark:border-dark-35 bg-light-20 dark:bg-dark-25 w-5 h-5 outline-none cursor-default hover:border-light-70 dark:hover:border-dark-40
focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 data-[disabled]:bg-light-10 dark:data-[disabled]:bg-dark-10 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20">
<RadioGroupIndicator>
<Icon icon="radix-icons:check" class="relative w-5 h-5 -top-px -left-px" />
</RadioGroupIndicator>
</RadioGroupItem>
{{ (option as RadioOption).label ?? option }}
</Label>
</RadioGroupRoot>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
export interface RadioOption {
label: string
value: string
disabled?: boolean
}
const { disabled = false, options } = defineProps<{
disabled?: boolean
options: string[] | RadioOption[]
}>();
const model = defineModel<string>();
</script>

View File

@ -0,0 +1,41 @@
<template>
<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
hover:border-light-50 dark:hover:border-dark-50">
<SelectValue :placeholder="placeholder" />
<Icon icon="radix-icons:caret-down" class="h-4 w-4" />
</SelectTrigger>
<SelectPortal :disabled="disabled">
<SelectContent :position="position"
class="min-w-[160px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-40">
<SelectScrollUpButton>
<Icon icon="radix-icons:chevron-up" />
</SelectScrollUpButton>
<SelectViewport>
<slot />
</SelectViewport>
<SelectScrollDownButton>
<Icon icon="radix-icons:chevron-down" />
</SelectScrollDownButton>
</SelectContent>
</SelectPortal>
</SelectRoot>
</Label>
</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
disabled?: boolean
position?: 'item-aligned' | 'popper'
label?: string
}>();
const model = defineModel<string>();
</script>

View File

@ -0,0 +1,14 @@
<template>
<SelectGroup :disabled="disabled" class="">
<SelectLabel class="">{{ label }}</SelectLabel>
<slot />
</SelectGroup>
</template>
<script setup lang="ts">
import { SelectGroup } from 'radix-vue';
const { label, disabled = false } = defineProps<{
label: string
disabled?: boolean
}>();
</script>

View 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>

View File

@ -0,0 +1,7 @@
<template>
<SelectSeparator class="" />
</template>
<script setup lang="ts">
import { SelectSeparator } from 'radix-vue';
</script>

View File

@ -0,0 +1,25 @@
<template>
<Label class="flex justify-center items-center my-2">{{ label }}
<SliderRoot class="mx-4 relative flex items-center select-none touch-none w-[160px] h-5"
:default-value="model ? [model] : undefined" :v-model="[model]" :disabled="disabled"
@update:model-value="(value) => model = value ? value[0] : min" :min="min" :max="max" :step="step">
<SliderTrack class="bg-light-30 dark:bg-dark-30 relative h-1 w-full data-[disabled]:bg-light-10 dark:data-[disabled]:bg-dark-10">
<SliderRange class="absolute bg-light-40 dark:bg-dark-40 h-full data-[disabled]:bg-light-30 dark:data-[disabled]:bg-dark-30" />
</SliderTrack>
<SliderThumb
class="block w-5 h-5 bg-light-50 dark:bg-dark-50 outline-none focus:shadow-raw transition-[box-shadow] focus:shadow-light-60 dark:focus:shadow-dark-60 border border-light-50 dark:border-dark-50
hover:border-light-60 dark:hover:border-dark-60 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20" />
</SliderRoot>
</Label>
</template>
<script setup lang="ts">
const { min = 0, max = 100, step = 1, label, disabled = false } = defineProps<{
min?: number
max?: number
step?: number
label?: string
disabled?: boolean
}>();
const model = defineModel<number>()
</script>

View 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>

View File

@ -0,0 +1,20 @@
<template>
<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" v-bind="$attrs">
</Label>
</template>
<script setup lang="ts">
const { type = 'text', label, disabled = false, placeholder } = defineProps<{
type?: 'text' | 'password' | 'email' | 'tel' | 'url'
label?: string
disabled?: boolean
placeholder?: string
}>();
const model = defineModel<string>();
</script>

View 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>

View 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>

View File

@ -0,0 +1,84 @@
<template>
<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 z-50" :side="side" :side-offset="['left', 'right'].includes(side ?? '') ? 8 : 0">
{{ message }}
<TooltipArrow class="fill-light-30 dark:fill-dark-30"></TooltipArrow>
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</template>
<script setup lang="ts">
const { message, delay = 300, side } = defineProps<{
message: string
delay?: number,
disabled?: boolean
side?: 'left' | 'right' | 'top' | 'bottom'
}>();
</script>
<style>
.TooltipContent {
animation-duration: .3s;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
.TooltipContent[data-side="top"] {
animation-name: slideUp;
}
.TooltipContent[data-side="bottom"] {
animation-name: slideDown;
}
.TooltipContent[data-side="left"] {
animation-name: slideLeft;
}
.TooltipContent[data-side="right"] {
animation-name: slideRight;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideRight {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideLeft {
from {
opacity: 0;
transform: translateX(10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>

50
components/base/Tree.vue Normal file
View 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>

View File

@ -1,6 +1,7 @@
<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
@ -180,43 +181,26 @@ const wheelHandler = useWheel(({ event: Event, delta: [x, y] }: { event: Event,
<div id="canvas" ref="canvas" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none"
:style="{ '--zoom-multiplier': (1 / Math.pow(zoom, 0.7)) }">
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 absolute sm:top-2 top-10 left-2 z-30 overflow-hidden">
<div @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"
aria-label="Zoom in" data-tooltip-position="left">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
</div>
<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" aria-label="Reset zoom"
data-tooltip-position="left">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
</svg>
</div>
<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" aria-label="Zoom to fit"
data-tooltip-position="left">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M8 3H5a2 2 0 0 0-2 2v3"></path>
<path d="M21 8V5a2 2 0 0 0-2-2h-3"></path>
<path d="M3 16v3a2 2 0 0 0 2 2h3"></path>
<path d="M16 21h3a2 2 0 0 0 2-2v-3"></path>
</svg>
</div>
<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"
aria-label="Zoom out" data-tooltip-position="left">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M5 12h14"></path>
</svg>
</div>
<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)`}">

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import type { CanvasNode } from '~/types/canvas';
interface Props {
@ -41,15 +42,7 @@ const colors = computed(() => {
</template>
<template v-else>
<div class="flex flex-1 justify-center items-center bg-light-30 dark:bg-dark-30">
<div class="">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="svg-icon lucide-align-left">
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="15" y1="12" x2="3" y2="12"></line>
<line x1="17" y1="18" x2="3" y2="18"></line>
</svg>
</div>
<Icon icon="radix-icons:text-align-left" class="w-8 h-8"/>
</div>
</template>
</div>

View File

@ -1,18 +0,0 @@
<script setup lang="ts">
const props = defineProps({
comments: {
type: [Object],
required: true,
}
})
</script>
<template>
<div v-if="comments !== undefined && comments !== null" class="site-body-right-column">
<div class="site-body-right-column-inner">
<div>
{{ comments.length }} commentaire{{ comments.length === 1 ? '' : 's' }}
</div>
</div>
</div>
</template>

View File

@ -1,20 +0,0 @@
<script setup lang="ts">
const route = useRoute();
const project = computed(() => parseInt(route.params.projectId as string));
const { data: navigation, refresh } = await useFetch(() => `/api/project/${project.value}/navigation`, { immediate: false });
if(!isNaN(project.value))
{
await refresh();
}
const emit = defineEmits(['navigate']);
</script>
<template>
<div class="relative flex-auto">
<div class="absolute top-0 bottom-0 left-0 right-0 overflow-auto xl:px-4 px-2">
<NavigationLink @navigate="e => emit('navigate')" class="ps-2" v-if="!!navigation" v-for="link of navigation" :project="project" :link="link" />
</div>
</div>
</template>

View File

@ -1,35 +0,0 @@
<script setup lang="ts">
import type { Navigation } from '~/types/api';
interface Props {
link: Navigation;
project?: number;
}
const props = defineProps<Props>();
const hasChildren = computed(() => {
return props.link && props.link.children && props.link.children.length > 0 || false;
});
const collapsed = ref(!unifySlug(useRoute().params.slug).startsWith(props.link.path));
const emit = defineEmits(['navigate']);
</script>
<template>
<div class="py-1" v-if="project && !isNaN(project)">
<Accordion v-if="hasChildren" v-model="collapsed" class="data-[type]:after:content-[attr(data-type)] data-[type]:after:border data-[type]:after:border-light-35 data-[type]:dark:after:border-dark-35
data-[type]:after:text-sm data-[type]:after:px-1 data-[type]:after:bg-light-20 data-[type]:dark:after:bg-dark-20 data-[type]:after:[font-variant:all-petite-caps]">
<template #header>{{ link.title }}</template>
<template #content >
<NavigationLink @navigate="e => emit('navigate')" class="border-light-40 dark:border-dark-40 ms-2 ps-4 border-l" v-if="hasChildren" v-for="l of link.children" :link="l" :project="project" />
</template>
</Accordion>
<NuxtLink v-else @click="e => emit('navigate')" class="text-light-100 dark:text-dark-100 cursor-pointer hover:text-opacity-75 xl:text-base text-sm flex justify-between items-center
data-[type]:after:content-[attr(data-type)] data-[type]:after:border data-[type]:after:border-light-35 data-[type]:dark:after:border-dark-35
data-[type]:after:text-sm data-[type]:after:px-1 data-[type]:after:bg-light-20 data-[type]:dark:after:bg-dark-20 data-[type]:after:[font-variant:all-petite-caps]" :to="{ path: `/explorer/${project}/${link.path}`, force: true }"
active-class="!text-accent-blue relative before:border-l-2 before:border-accent-blue before:absolute before:-top-1 before:-bottom-1 before:-left-4 before:-mx-px" :data-type="(link.type === 'Canvas' ? 'graph' : (link.private ? 'privé' : undefined))">
<div>{{ link.title }}</div>
</NuxtLink>
</div>
</template>

View File

@ -1,65 +0,0 @@
<template>
<HoverPopup @before-show="fetch">
<template #content>
<Suspense suspensible>
<div :class="[{'is-loaded': fetched}, file?.type === 'Markdown' ? 'overflow-auto' : 'overflow-hidden']">
<div v-if="pending" class="loading w-[550px] h-[450px]"></div>
<template v-else-if="!!file">
<div v-if="file.type === 'Markdown'" class="p-6 ms-6">
<ProseH1>{{ file.title }}</ProseH1>
<Markdown :content="file.content"></Markdown>
</div>
<div v-else-if="file.type === 'Canvas'" class="w-[550px] h-[450px] overflow-hidden">
<CanvasRenderer :canvas="JSON.parse(file.content) " />
</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-[550px] h-[450px]"></div></template>
</Suspense>
</template>
<template #default><slot name="default"></slot></template>
</HoverPopup>
</template>
<script setup lang="ts">
import type { CommentedFile } from '~/types/api';
const props = defineProps({
project: {
type: Number,
required: true,
},
path: {
type: String,
required: true,
},
anchor: {
type: String,
required: false,
}
})
const file = ref<CommentedFile | null>();
const pending = ref(false), fetched = ref(false);
async function fetch()
{
if(fetched.value)
return;
fetched.value = true;
pending.value = true;
const data = await $fetch(`/api/project/${props.project}/file/${encodeURIComponent(props.path)}`);
pending.value = false;
file.value = data;
}
</script>

View File

@ -1,50 +1,60 @@
<template>
<Suspense suspensible>
<NuxtLink no-prefetch class="text-accent-blue inline-flex items-center" v-if="data && data[0]"
:to="{ path: `/explorer/${project}/${data[0].path}`, hash: hash }" :class="class">
<PreviewContent :project="project" :path="data[0].path" :anchor="hash">
<template #default>
<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 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>
</PreviewContent>
</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>
</Suspense>
<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 props = defineProps({
href: {
type: String,
required: false,
},
class: {
type: String,
required: false,
}
});
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 route = useRoute();
const { hash, pathname, protocol } = parseURL(props.href);
const project = computed(() => parseInt(Array.isArray(route.params.projectId) ? '0' : route.params.projectId));
const data = ref();
const { hash, pathname, protocol } = parseURL(href);
const data = ref(), loading = ref(false);
if(!!pathname && !protocol)
{
data.value = await $fetch(`/api/project/${project.value}/file`, {
query: {
search: `%${pathname}`
},
ignoreResponseError: true,
});
loading.value = true;
try {
data.value = await $fetch(`/api/file`, {
query: {
search: `%${pathname}`
},
});
} catch(e) { }
loading.value = false;
}
</script>

View File

@ -162,17 +162,14 @@ blockquote:empty
@apply w-6;
@apply h-6;
@apply stroke-2;
}
.callout-title
{
@apply flex;
@apply items-center;
@apply gap-2;
@apply float-start;
@apply me-2;
}
.callout-title-inner
{
@apply inline-block;
@apply block;
@apply font-bold;
@apply ps-8;
}
.callout > p
{

View File

@ -1,5 +1,5 @@
<template>
<h1 :id="parseId(id)" class="text-5xl font-thin mt-3 mb-8 first:pt-0 pt-2 relative sm:right-8 right-4">
<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>

View File

@ -1,5 +1,5 @@
<template>
<h2 :id="parseId(id)" class="text-4xl font-semibold mt-3 mb-6 ms-1 first:pt-0 pt-2 relative sm:right-8 right-4">
<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>

View File

@ -1,3 +1,3 @@
<template>
<hr class="border-light-35 dark:border-dark-35 m-4">
<Separator class="border-light-35 dark:border-dark-35 m-4" />
</template>

View File

@ -1,5 +1,5 @@
<template>
<HoverPopup @before-show="fetch">
<!-- <HoverPopup @before-show="fetch">
<template #content>
<Suspense suspensible>
<div class="mw-[400px]">
@ -27,10 +27,13 @@
<slot></slot>
</span>
</template>
</HoverPopup>
</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">
<!-- <script setup lang="ts">
import type { Tag } from '~/types/api';
const { tag } = defineProps({
@ -51,4 +54,4 @@ async function fetch()
data.value = await $fetch(`/api/project/${project.value}/tags/${encodeURIComponent(tag)}`);
fetched.value = true;
}
</script>
</script> -->

View File

@ -1,5 +0,0 @@
<template>
<span class="text-accent-blue inline-flex items-center cursor-pointer">
<slot></slot>
</span>
</template>

View File

@ -1,182 +0,0 @@
<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;
}
.callout-title
{
@apply flex;
@apply items-center;
@apply gap-2;
}
.callout-title-inner
{
@apply inline-block;
@apply font-bold;
}
.callout > p
{
@apply mt-2;
@apply font-semibold;
}
</style>

View File

@ -1,9 +0,0 @@
<template>
<h1 :id="parseId(id)" class="text-5xl font-thin mt-3 mb-8 first:pt-0 pt-2">
<slot />
</h1>
</template>
<script setup lang="ts">
const props = defineProps<{ id?: string }>()
</script>

View File

@ -1,11 +0,0 @@
<template>
<h2 :id="parseId(id)" class="text-4xl font-semibold mt-3 mb-6 ms-1 first:pt-0 pt-2">
<slot />
</h2>
</template>
<script setup lang="ts">
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)
</script>

View File

@ -1,11 +0,0 @@
<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>

View File

@ -1,9 +0,0 @@
<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>

View File

@ -1,11 +0,0 @@
<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>

View File

@ -1,11 +0,0 @@
<template>
<h6 :id="parseId(id)">
<slot />
</h6>
</template>
<script setup lang="ts">
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)
</script>

View File

@ -1,5 +0,0 @@
<template>
<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>

View File

@ -1,19 +0,0 @@
<script setup lang="ts">
const collapsed = defineModel<boolean>({
default: true,
});
</script>
<template>
<div @click="collapsed = !collapsed" class="flex gap-2 items-center cursor-pointer hover:text-opacity-75 text-light-100 dark:text-dark-100" :class="$attrs.class">
<div class="w-4 h-4 transition-transform" :class="{'-rotate-90': collapsed}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8L12 17L21 8"></path>
</svg>
</div>
<div class="font-semibold xl:text-base text-sm flex-1"><slot name="header"></slot></div>
</div>
<div v-if="!collapsed">
<slot name="content"></slot>
</div>
</template>

View File

@ -1,41 +0,0 @@
<template>
<Teleport to="#teleports" v-if="visible">
<div @click.self="() => !focused && hide()" class="z-[100] absolute top-0 bottom-0 left-0 right-0 bg-light-0 dark:bg-dark-0 !bg-opacity-80 flex justify-center items-center">
<div class="relative border border-light-35 dark:border-dark-35 bg-light-0 dark:bg-dark-0 min-h-40 min-w-72">
<span v-if="closeIcon" class="cursor-pointer absolute top-1 right-1 flex hover:opacity-75" @click="() => hide()"><Icon :width="20" :height="20" icon="icons/close" /></span>
<div class="p-6" :class="$attrs.class">
<slot></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
const props = defineProps({
focused: {
type: Boolean,
defualt: false,
},
closeIcon: {
type: Boolean,
default: true,
}
})
const emit = defineEmits(['show', 'hide']);
const visible = ref(false);
function show()
{
visible.value = true;
emit('show');
}
function hide()
{
visible.value = false;
emit('hide');
}
defineExpose({hide, show});
</script>

View File

@ -1,27 +0,0 @@
<template>
<div>
{{beforeText}}<span class="font-bold">{{matchedText}}</span>{{afterText}}
</div>
</template>
<script setup lang="ts">
interface Prop
{
text: string;
matched: string;
}
const props = defineProps<Prop>();
const beforeText = computed(() => {
const pos = props.text.toLowerCase().indexOf(props.matched.toLowerCase());
return props.text.substring(0, pos);
})
const matchedText = computed(() => {
const pos = props.text.toLowerCase().indexOf(props.matched.toLowerCase());
return props.text.substring(pos, pos + props.matched.length);
})
const afterText = computed(() => {
const pos = props.text.toLowerCase().indexOf(props.matched.toLowerCase()) + props.matched.length;
return props.text.substring(pos);
})
</script>

View File

@ -1,52 +0,0 @@
<template>
<Teleport to="#teleports" v-if="display">
<div class="absolute border-2 border-light-35 dark:border-dark-35 max-w-[550px] max-h-[450px] bg-light-0 dark:bg-dark-0 text-light-100 dark:text-dark-100 overflow-hidden" :style="pos"
@mouseenter="debounce(show, 250)" @mouseleave="debounce(() => { emit('beforeHide'); display = false }, 250)">
<slot name="content"></slot>
</div>
</Teleport>
<span ref="el" :class="$attrs.class" @mouseenter="debounce(show, 250)" @mouseleave="debounce(() => { emit('beforeHide'); display = false }, 250)">
<slot name="default"></slot>
</span>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue';
const display = ref(false), fetched = ref(false);
const el = useTemplateRef('el'), pos = ref<Record<string, string>>();
const emit = defineEmits(['beforeShow', 'beforeHide']);
async function show()
{
if(display.value)
return;
emit('beforeShow');
const rect = (el.value as HTMLDivElement)?.getBoundingClientRect();
if(!rect)
return;
const r: Record<string, string> = {};
if (rect.bottom + 450 < window.innerHeight)
r.top = (rect.bottom + 4) + "px";
else
r.bottom = (window.innerHeight - rect.top + 4) + "px";
if (rect.right + 550 < window.innerWidth)
r.left = rect.left + "px";
else
r.right = (window.innerWidth - rect.right + 4) + "px";
pos.value = r;
display.value = true;
}
let debounceFn: () => void, timeout: NodeJS.Timeout;
function debounce(fn: () => void, ms: number)
{
debounceFn = fn;
clearTimeout(timeout);
timeout = setTimeout(debounceFn, ms);
}
</script>

View File

@ -1,13 +0,0 @@
<script setup lang="ts">
interface Prop
{
icon: string;
width: number;
height: number;
}
defineProps<Prop>();
</script>
<template>
<span :class="$attrs.class" class="inline-block bg-light-100 dark:bg-dark-100 [mask-size:100%] [mask-repeat:no-repeat] [mask-position:center]" :style="`width: ${width}px; height: ${height}px; mask-image: url(/${icon}.svg)`"></span>
</template>

View File

@ -1,9 +0,0 @@
<script setup lang="ts">
const emit = defineEmits(['input', 'change', 'focus', 'blur']);
const model = defineModel();
</script>
<template>
<input v-model="model" v-bind="$attrs" @input="e => emit('input', e)" @change="e => emit('change', e)" @focus="e => emit('focus', e)" @blur="e => emit('blur', e)"
class="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"/>
</template>

View File

@ -1,21 +0,0 @@
<script setup lang="ts">
interface Prop
{
error?: string | boolean;
title: string;
}
const props = defineProps<Prop>();
const model = defineModel<string>();
const err = ref<string | boolean | undefined>(props.error);
watchEffect(() => err.value = props.error);
</script>
<template>
<div class="m-1 flex justify-between items-center gap-8">
<label v-if="title" class="pe-4">{{ title }}</label>
<Input class="flex-1" @input="err = false" :class="{ 'input-has-error': !!err }" v-model="model" v-bind="$attrs" />
</div>
<span v-if="err && typeof err === 'string'" class="text-light-red dark:text-dark-red block pb-2">{{ err }}</span>
</template>

View File

@ -1,24 +0,0 @@
<template>
<template
v-if="content && content.length > 0">
<Suspense :timeout="0">
<MarkdownRenderer #default :key="key" v-if="node" :node="node"></MarkdownRenderer>
<template #fallback><div class="loading"></div></template>
</Suspense>
</template>
</template>
<script setup lang="ts">
import { hash } from 'ohash'
const { content } = defineProps({
content: {
type: String,
required: true,
}
})
const parser = useMarkdown();
const key = computed(() => hash(content));
const node = computed(() => content ? parser(content) : undefined);
</script>

View File

@ -1,76 +0,0 @@
<script setup lang="ts">
import { useTemplateRef } from 'vue';
const containerRef = useTemplateRef("container");
const { loggedIn, user } = useUserSession();
const { direction } = useSwipe(window, { threshold: 150 });
watch(direction, () => {
if(direction.value === 'right')
toggleNavigation(true);
if(direction.value === 'left')
toggleNavigation(false);
});
function toggleNavigation(bool?: boolean)
{
containerRef.value?.setAttribute('aria-expanded', bool === undefined ? (containerRef.value?.getAttribute('aria-expanded') !== 'true').toString() : bool.toString());
}
const hideNavigation = () => toggleNavigation(false);
onMounted(() => {
const links = containerRef.value?.getElementsByTagName('a');
if(links)
{
for(const link of links)
{
link.addEventListener("click", hideNavigation);
}
}
})
onUnmounted(() => {
const links = containerRef.value?.getElementsByTagName('a');
if(links)
{
for(const link of links)
{
link.addEventListener("click", hideNavigation);
}
}
})
</script>
<template>
<div ref="container" aria-expanded="false" class="group flex h-screen overflow-hidden">
<div class="z-50 sm:hidden block absolute top-0 left-0 p-2 border-e border-b border-light-35 dark:border-dark-35 bg-light-0 dark:bg-dark-0 cursor-pointer hover:bg-light-25 dark:hover:bg-dark-25" @click="e => toggleNavigation()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="w-4 h-4 fill-light-100 dark:fill-dark-100">
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="15" y1="12" x2="3" y2="12"></line>
<line x1="17" y1="18" x2="3" y2="18"></line>
</svg>
</div>
<div class="bg-light-0 sm:my-8 sm:py-3 dark:bg-dark-0 top-0 z-40 xl:w-96 sm:w-[15em] w-full border-r border-light-30 dark:border-dark-30 flex flex-col justify-between max-sm:absolute max-sm:-top-0 max-sm:-bottom-0 sm:left-0 max-sm:-left-full max-sm:group-aria-expanded:left-0 max-sm:transition-[left] py-8 max-sm:z-40">
<div class="relative bottom-6 flex flex-1 flex-col gap-4 xl:px-6 px-3">
<div class="flex justify-between items-center">
<NuxtLink @click="hideNavigation" class=" text-light-100 dark:text-dark-100 hover:text-opacity-70 max-sm:ps-6" aria-label="Accueil" :to="{ path: '/', force: true }"><ThemeIcon class="inline" icon="logo" :width=56 :height=56 /></NuxtLink>
<div class="flex gap-4 items-center">
<ThemeSwitch />
<NuxtLink @click="hideNavigation" class="" :to="{ path: '/user/profile', force: true }"><div class=" hover:border-opacity-70 flex border p-px border-light-70 dark:border-dark-70">
<Icon v-if="!loggedIn" icon="icons/user-login" :width=28 :height=28 />
<Picture v-else :src="`/users/${user?.id}/small.jpg`" :width=28 :height=28 class="flex" >
<Icon :icon="`icons/unknown`" :width=28 :height=28 ></Icon>
</Picture>
</div></NuxtLink>
</div>
</div>
<div class="flex"><SearchView @navigate="hideNavigation" /></div>
<slot></slot>
</div>
<div class="text-center xl:text-sm text-xs text-light-70 dark:text-dark-70">
<NuxtLink class="hover:underline italic" :to="{ path: '/third-party', force: true }">Mentions légales</NuxtLink>
<p>Copyright Peaceultime - 2024</p>
</div>
</div>
</div>
</template>

View File

@ -1,31 +0,0 @@
<template>
<span>
<img v-show="src && !fallback" @load="hideFallback" @error="showFallback" :src="src" :width="width" :height="height" />
<span v-show="!src || fallback"><slot></slot></span>
</span>
</template>
<script setup lang="ts">
const props = defineProps({
src: {
type: String,
},
width: {
type: Number,
},
height: {
type: Number,
}
});
const fallback = ref(false);
const showFallback = () => toggleFallback(true);
const hideFallback = () => toggleFallback(false);
function toggleFallback(toggle: boolean): void
{
console.log("Something happened")
fallback.value = toggle;
}
</script>

View File

@ -1,91 +0,0 @@
<script setup lang="ts">
import type { Search, File, Project, User } from '~/types/api';
const input = ref(''), results = ref<Search>({ projects: [], files: [], users: [] });
const pos = ref<DOMRect>(), loading = ref(false);
let timeout: NodeJS.Timeout;
const emit = defineEmits(['navigate']);
function search(e: Event)
{
pos.value = (e.currentTarget as HTMLElement)?.getBoundingClientRect();
loading.value = true;
clearTimeout(timeout);
timeout = setTimeout(debounced, 250);
}
async function debounced()
{
if(!input.value)
return;
try
{
const fetch = await useFetch(`/api/search`, {
query: {
"search": `%${input.value}%`
}
});
results.value = fetch.data.value;
}
catch (e) {
results.value = { projects: [], files: [], users: [] };
}
loading.value = false;
}
</script>
<template>
<div class="w-full border border-light-30 dark:border-dark-30">
<Input class="w-full" type="text" placeholder="Recherche" v-model="input" @input="search" />
</div>
<Teleport to="#teleports" v-if="input !== '' && !!pos">
<div class="absolute z-50 border border-light-35 dark:border-dark-35 bg-light-0 dark:bg-dark-0 text-light-100 dark:text-dark-100 divide-y divide-light-35 dark:divide-dark-35 max-h-96 overflow-auto
scroll"
:style="{top: (pos.bottom + 4) + 'px', left: pos.left + 'px', width: pos.width + 'px'}">
<div class="loading" v-if="loading"></div>
<template v-else>
<div class="cursor-pointer hover:bg-light-25 dark:hover:bg-dark-25 px-4 py-1 " v-for="result of results.projects" :key="result.id"
@mouseenter="(e) => (e.target as HTMLElement).classList.add('is-selected')"
@mouseleave="(e) => (e.target as HTMLElement).classList.remove('is-selected')"
@mousedown.prevent="navigateTo(`/explorer/${result.id}/${result.home}`); input = ''; emit('navigate');">
<div class="">
<Highlight class="text-lg" :text="result.name" :matched="input" />
<div class="flex justify-between text-sm">
<div class="">{{ result.username }}</div>
<div class="">{{ result.pages }} pages</div>
</div>
</div>
</div>
<div class="cursor-pointer hover:bg-light-25 dark:hover:bg-dark-25 px-4 py-1 " v-for="result of results.files" :key="result.id"
@mouseenter="(e) => (e.target as HTMLElement).classList.add('is-selected')"
@mouseleave="(e) => (e.target as HTMLElement).classList.remove('is-selected')"
@mousedown.prevent="navigateTo(`/explorer/${result.project}/${result.path}`); input = ''; emit('navigate');">
<div class="">
<Highlight class="text-lg" :text="result.title" :matched="input" />
<div class="flex justify-between text-sm">
<div class="">{{ result.username }}</div>
<div class="">{{ result.comments }} commentaires</div>
</div>
</div>
</div>
<div class="cursor-pointer hover:bg-light-25 dark:hover:bg-dark-25 px-4 py-1 " v-for="result of results.users" :key="result.id"
@mouseenter="(e) => (e.target as HTMLElement).classList.add('is-selected')"
@mouseleave="(e) => (e.target as HTMLElement).classList.remove('is-selected')"
@mousedown.prevent="navigateTo(`/users/${result.id}`); input = ''; emit('navigate');">
<div class="">
<Highlight class="text-lg" :text="result.username" :matched="input" />
</div>
</div>
<div class=""
v-if="results.projects.length === 0 && results.files.length === 0 && results.users.length === 0">
Aucun résultat
</div>
</template>
</div>
</Teleport>
</template>

View File

@ -1,16 +0,0 @@
<script setup lang="ts">
interface Prop
{
icon: string;
width: number;
height: number;
}
defineProps<Prop>();
</script>
<template>
<span>
<img :src="`/icons/${icon}.light.svg`" class="block dark:hidden" :width="width" :height="height" />
<img :src="`/icons/${icon}.dark.svg`" class="dark:block hidden" :width="width" :height="height" />
</span>
</template>

View File

@ -1,39 +0,0 @@
<script setup>
const colorMode = useColorMode()
const isDark = computed({
get() {
return colorMode.value === 'dark'
},
set() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
})
</script>
<template>
<div class="flex relative">
<span class="hidden dark:block absolute top-[3px] left-[2px] z-[1] px-[5px]">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 stroke-light-100 dark:stroke-dark-100">
<path d="M12 3a6.364 6.364 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>
</span>
<div class="
before:absolute before:top-0 before:left-0 before:right-0 before:bottom-0 before:opacity-0 before:block before:z-10
after:transition-all after:w-4 after:h-4 after:border after:border-light-30 dark:after:border-dark-30 after:block after:m-[3px] after:top-[-1px] after:pointer-events-none after:absolute after:left-0 after:bg-light-0 after:translate-x-[1px] dark:after:translate-x-[26px]
inline-block relative cursor-pointer w-[50px] h-[22px] select-none border border-light-35 dark:border-dark-35 bg-light-0 dark:bg-dark-30 dark:hover:border-dark-35" @click="isDark = !isDark"></div>
<span class="block dark:hidden absolute top-[3px] left-[22px] z-[1] px-[5px]">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 stroke-light-100 dark:stroke-dark-100 ">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>
</span>
</div>
</template>

View File

@ -1,27 +0,0 @@
<template>
<label>
<slot></slot>
<input ref="input" :accept="accept" :multiple="multiple" type="file" class="hidden" @change.self="(e) => files = [...(e.target as HTMLInputElement)?.files ?? []]"/>
</label>
</template>
<script setup lang="ts">
const props = defineProps({
accept: {
type: String,
},
multiple: {
type: Boolean,
default: false,
}
});
const input = useTemplateRef('input');
const files = ref<File[]>([]);
watch([files], () => emit('change', files.value));
const emit = defineEmits(['change']);
defineExpose({ files });
</script>

View File

@ -1,21 +1,19 @@
import { Database } from "bun:sqlite";
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from '../db/schema';
let instance: Database | undefined;
let instance: BunSQLiteDatabase<typeof schema>;
export default function useDatabase()
{
if(!instance)
{
const database = useRuntimeConfig().database;
const sqlite = new Database(database);
instance = drizzle({ client: sqlite, schema });
export default function useDatabase(): Database {
if(instance === undefined)
instance = getDatabase();
instance.run("PRAGMA journal_mode = WAL;");
instance.run("PRAGMA foreign_keys = true;");
}
return instance;
}
function getDatabase(): Database
{
const { dbFile } = useRuntimeConfig();
const db = new Database(dbFile);
db.exec("PRAGMA journal_mode = WAL;");
return db;
}

View File

@ -16,7 +16,7 @@ export default function useMarkdown(): (md: string) => Root
const parse = (markdown: string) => {
if (!processor)
{
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]);
processor = unified().use([RemarkParse, RemarkGfm , RemarkOfm , RemarkBreaks, RemarkFrontmatter]);
processor.use(RemarkRehype, { allowDangerousHtml: true });
processor.use(RehypeRaw);
}

View File

@ -1,50 +0,0 @@
import type { Project } from "~/types/api";
export default function useProject()
{
const project = useCookie('project');
const id = useState<number>("projectId", () => parseInt(project.value ?? '0'));
const name = useState<string>("projectName", undefined);
const owner = useState<number>("projectOwner", undefined);
const home = useState<string | null>("projectHomepage", () => null);
const summary = useState<string | null>("projectSummary", () => null);
return {
id, name, owner, home, summary, get, set
};
}
async function get(): Promise<boolean> {
const id = useState<number>("projectId");
if (!id.value)
return false;
try {
const result = await $fetch(`/api/project/${id.value}`) as Project;
const name = useState<string>("projectName");
const owner = useState<number>("projectOwner");
const home = useState<string | null>("projectHomepage");
const summary = useState<string | null>("projectSummary");
name.value = result.name;
owner.value = result.owner;
home.value = result.home;
summary.value = result.summary;
return true;
} catch(e) {
return false;
}
}
async function set(id: number): Promise<boolean> {
const _id = useState<number>("projectId");
_id.value = id;
const project = useCookie('project');
project.value = id.toString();
return await get();
}

40
composables/useToast.ts Normal file
View 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;
}

BIN
db.sqlite

Binary file not shown.

BIN
db.sqlite-shm Normal file

Binary file not shown.

BIN
db.sqlite-wal Normal file

Binary file not shown.

63
db/schema.ts Normal file
View File

@ -0,0 +1,63 @@
import { relations } from 'drizzle-orm';
import { int, text, sqliteTable, type SQLiteTableExtraConfig, primaryKey, blob } from 'drizzle-orm/sqlite-core';
export const usersTable = sqliteTable("users", {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
email: text().notNull().unique(),
hash: text().notNull().unique(),
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", {
id: int().notNull(),
user_id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}, (table): SQLiteTableExtraConfig => {
return {
pk: primaryKey({ columns: [table.id, table.user_id] }),
}
});
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' }),
title: text().notNull(),
type: text({ enum: ['file', 'folder', 'markdown', 'canvas'] }).notNull(),
content: blob({ mode: 'buffer' }),
navigable: int({ mode: 'boolean' }).default(true),
private: int({ mode: 'boolean' }).default(false),
});
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], }),
}));

11
drizzle.config.ts Normal file
View File

@ -0,0 +1,11 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './db/schema.ts',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DB_FILE!,
},
});

View File

@ -0,0 +1,36 @@
CREATE TABLE `explorer_content` (
`path` text PRIMARY KEY NOT NULL,
`owner` integer NOT NULL,
`title` text NOT NULL,
`type` text NOT NULL,
`content` blob,
`navigable` integer DEFAULT true,
`private` integer DEFAULT false,
FOREIGN KEY (`owner`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `user_sessions` (
`id` integer NOT NULL,
`user_id` integer NOT NULL,
`timestamp` integer NOT NULL,
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> 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
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`email` text NOT NULL,
`hash` text NOT NULL,
`state` integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_hash_unique` ON `users` (`hash`);

View 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
);

View 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;

View File

@ -0,0 +1,252 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f66f1f97-ceb3-46ed-988b-62828fe4a6a6",
"prevId": "00000000-0000-0000-0000-000000000000",
"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_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -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": {}
}
}

View 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": {}
}
}

View File

@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"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
View 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
View 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
View 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>

View File

@ -1,9 +1,75 @@
<template>
<NavBar>
<NuxtLink class="xl:px-6 px-3 text-lg xl:tracking-wider flex items-center font-semibold text-light-100 dark:text-dark-100 hover:text-opacity-70" aria-label="Projets" :to="{ path: `/explorer`, force: true }">Projets</NuxtLink>
<NuxtLink class="xl:px-6 px-3 text-lg xl:tracking-wider flex items-center font-semibold text-light-100 dark:text-dark-100 hover:text-opacity-70" aria-label="Editeur" :to="{ path: '/editing', force: true }">Editeur</NuxtLink>
</NavBar>
<div class="flex-1 flex items-baseline overflow-auto py-8 sm:px-8 px-4 relative">
<slot></slot>
</div>
<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>

View File

@ -1,11 +0,0 @@
<template>
<NavBar>
<NuxtLink class="xl:px-6 px-3 text-lg xl:tracking-wider flex items-center font-semibold text-light-100 dark:text-dark-100 hover:text-opacity-70" aria-label="Projets" :to="{ path: `/explorer`, force: true }">Projets</NuxtLink>
<NuxtLink class="xl:px-6 px-3 text-lg xl:tracking-wider flex items-center font-semibold text-light-100 dark:text-dark-100 hover:text-opacity-70" aria-label="Editeur" :to="{ path: '/editing', force: true }">Editeur</NuxtLink>
<hr class="border-light-35 dark:border-dark-35"/>
<ExplorerNavigation></ExplorerNavigation>
</NavBar>
<div class="flex-1 flex items-baseline overflow-auto py-8 sm:px-8 px-4 relative">
<slot></slot>
</div>
</template>

8
layouts/login.vue Normal file
View 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>

View File

@ -1,9 +1,8 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const { loggedIn, ready, fetch } = useUserSession();
const { loggedIn, fetch, user } = useUserSession();
const meta = to.meta;
if(!ready)
await fetch();
await fetch();
if(!!meta.guestsGoesTo && !loggedIn.value)
{
@ -11,12 +10,27 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
}
else if(meta.requireAuth && !loggedIn.value)
{
return abortNavigation();
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
View 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" });

View File

@ -1,75 +0,0 @@
import { defineNuxtModule } from '@nuxt/kit'
import { Database } from "bun:sqlite";
interface Schema
{
type: string;
name: string;
tbl_name: string;
sql: string;
}
interface Structure
{
cid: number;
name: string;
type: string;
[key: string]: number | string | null;
}
export default defineNuxtModule({
setup (options, nuxt) {
nuxt.hook('build:done', () => {
console.log("Building database");
const template = new Database(nuxt.options.runtimeConfig.templateFile);
const schema = template.query(`SELECT * FROM sqlite_schema WHERE type = 'table'`).all() as Schema[];
const structure = template.query(`SELECT * FROM pragma_table_info(?1)`);
const db = new Database(nuxt.options.runtimeConfig.dbFile);
db.exec(`PRAGMA foreign_keys = OFF; PRAGMA defer_foreign_keys = OFF;`);
const oldSchema = db.query(`SELECT * FROM sqlite_schema WHERE type = 'table'`).all() as Schema[];
const oldStructure = db.query(`SELECT * FROM pragma_table_info(?1)`);
(db.transaction((tables: Schema[], oldTables: Schema[]) => {
for(const table of tables)
{
const oldIdx = oldTables.findIndex(e => e.name === table.name);
if(table.name === 'sqlite_sequence')
{
oldTables.splice(oldIdx, 1);
continue;
}
const columns = structure.all(table.name) as Structure[];
const oldColumns = oldStructure.all(table.name) as Structure[];
if(oldIdx !== -1)
{
oldTables.splice(oldIdx, 1);
const filteredColumns = oldColumns.filter(e => columns.find(f => e.name === f.name)).map(e => `"${e.name}"`).join(', ')
db.exec(table.sql.replace(`CREATE TABLE "${table.name}"`, `CREATE TABLE "${table.name}_new"`));
db.exec(`INSERT INTO ${table.name}_new (${filteredColumns}) SELECT ${filteredColumns} FROM ${table.name}`);
db.exec(`DROP TABLE ${table.name}`);
db.exec(`ALTER TABLE ${table.name}_new RENAME TO ${table.name}`);
}
else
{
db.exec(table.sql);
}
}
for(const table of oldTables)
{
db.exec(`DROP TABLE ${table.name}`);
}
})).immediate(schema, oldSchema);
db.exec(`PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;`);
db.close();
template.close();
});
}
})

View File

@ -1,38 +1,61 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import DatabaseSync from './modules/database.sync/module';
export default defineNuxtConfig({
compatibilityDate: '2024-04-03',
modules: [
"@nuxtjs/color-mode",
"nuxt-security",
"@nuxtjs/tailwindcss",
"@vueuse/nuxt",
DatabaseSync
'@nuxtjs/color-mode',
'nuxt-security',
'@nuxtjs/tailwindcss',
'@vueuse/nuxt',
'radix-vue/nuxt',
],
runtimeConfig: {
dbFile: 'db.sqlite',
templateFile: 'template.sqlite',
session: {
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
}
},
tailwindcss: {
viewer: false,
config: {
theme: {
extend: {
boxShadow: {
raw: '0 0 0 4px var(--tw-shadow-color)'
}
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',
@ -40,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',
@ -94,9 +119,12 @@ export default defineNuxtConfig({
experimental: {
tasks: true,
},
scheduledTasks: {
'0 */1 * * *': ['sync']
}
},
runtimeConfig: {
session: {
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
},
database: 'db.sqlite'
},
security: {
rateLimiter: false,
@ -104,7 +132,7 @@ export default defineNuxtConfig({
contentSecurityPolicy: {
"img-src": "'self' data: blob:"
}
}
},
xssValidator: false,
},
compatibilityDate: '2024-07-25',
})

View File

@ -1,28 +1,29 @@
{
"devDependencies": {
"@nuxtjs/color-mode": "^3.4.4",
"@nuxtjs/tailwindcss": "^6.12.1",
"@types/bun": "^1.1.8",
"@types/diff": "^5.2.2",
"@vueuse/gesture": "^2.0.0",
"@vueuse/nuxt": "^11.0.3",
"hast-util-to-html": "^9.0.2",
"nuxt": "^3.13.1",
"nuxt-security": "^2.0.0-rc.9",
"remark-breaks": "^4.0.0",
"remark-ofm": "link:remark-ofm",
"vue": "^3.5.3",
"vue-router": "^4.4.3",
"zod": "^3.23.8"
"name": "d-any",
"private": true,
"type": "module",
"scripts": {
"predev": "bun i && bunx nuxi cleanup",
"dev": "bunx --bun nuxi dev"
},
"dependencies": {
"diff": "^5.2.0",
"lodash.capitalize": "^4.2.1",
"rehype-raw": "^7.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"unified": "^11.0.5"
"@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.14.159",
"nuxt-security": "^2.0.0",
"radix-vue": "^1.9.8",
"vue": "latest",
"vue-router": "latest",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bun": "^1.1.12",
"better-sqlite3": "^11.5.0",
"bun-types": "^1.1.34",
"drizzle-kit": "^0.26.2"
}
}

View File

@ -1,9 +1,3 @@
<template>
<Head>
<Title>Inconnu</Title>
</Head>
<div class="h-100 w-100 flex flex-1 flex-col justify-center items-center">
<div class="text-3xl font-extralight tracking-wide text-light-60 dark:text-dark-60">Introuvable</div>
<div class="text-lg text-light-60 dark:text-dark-60">Cette page n'existe pas</div>
</div>
Page inconnue.
</template>

56
pages/admin/index.vue Normal file
View File

@ -0,0 +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>
<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>

View File

@ -1,223 +0,0 @@
<style>
.editor-container
{
width: 100%;
height: 100%;
overflow: auto;
display: flex;
justify-content: space-around;
}
.editor
{
width: 45%;
}
</style>
<template>
<Head>
<Title>Live Editing</Title>
</Head>
<div class="flex flex-1 flex-col justify-center items-center">
<h1 class="block flex-1 text-3xl">En cours de développement</h1>
<div class="flex flex-1">
<Suspense>
<template #fallback>
<div class="loading"></div>
</template>
<EditableMarkdown class="editor-preview" v-if="input.length > 0" v-model="input"></EditableMarkdown>
</Suspense>
<textarea class="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 w-1/2 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" v-model="input"></textarea>
</div>
</div>
</template>
<script setup lang="ts">
const input = ref(`## Liste de sorts provisoire
%% Equilibrage: Les sorts de dégâts plus cher ne doivent pas forcément proposer plus de dés de dégâts mais offrir plus d'options et avoir des dé de dégâts plus haut, pour synergiser avec les buffs de l'arbre de magie. %%
**Notation:**
- Nom #element (coût, durée d'incantation, portée, prérequis d'incantation)
> Effet
### Rang 1
- Trait de feu #element/feu (4 mana, tour, 12 cases, V/Ge/Gl)
>Tire un faisceau de flamme, infligeant 2d8 dégâts de [[Les types de dégâts#Feu|feu]].
- Echauffement #element/feu (2 mana, tour, V/Gl)
>Chauffe à blanc une arme ou un projectile. Jusqu'au début de votre prochain tour, les coups portés avec l'objet infligent 1d6 dégâts supplémentaire. Les dégâts de l'arme deviennent des dégâts de [[Les types de dégâts#Feu|feu]].
- #element/feu (mana, tour)
>
- Corps ardent #element/feu(6 mana, tour, V/Ge/Gl/C)
>Pendant 5 tours, toute personne terminant son tour à une case de vous subit 1d10 dégâts de [[Les types de dégâts#Feu|feu]].
- #element/feu(mana, tour)
>
- Protection supérieure #element/glace (2 mana, réaction, V/Gl)
>L'armure subit l'intégralité des dégâts sur le prochain coup.
- Lames de glace #element/glace (4 mana, tour, 12 cases)
>Tire 2 projectiles infligeant 1d8 dégâts de [[Les types de dégâts#Glace|glace]]. Augmenter les dés de dégâts offre un projectile supplémentaire à la place. Chaque projectile demande un jet d'attaque séparé et peut viser une cible différente.
- #element/glace(mana, tour)
>
- #element/glace(mana, tour)
>
- #element/glace(mana, tour)
>
- Chaine de foudre #element/foudre (4 mana, tour, 12 cases)
>Frappe une cible visible puis rebondit sur jusqu'à 2 autres cibles à 1 case de la première. Inflige 1d8 dégâts de [[Les types de dégâts#Foudre|foudre]].
- Vitesse lumière #element/foudre (3 mana, tour)
>Se téléporte à 6 cases tant que vous pouvez voir et courir vers la destination.
- Décharge de foudre #element/foudre(3 mana, tour)
>Tire une décharge foudroyante d'énergie, infligeant 4d4[[Glossaire#Jet explosif|!]] dégâts de [[Les types de dégâts#Foudre|foudre]].
- Faisceau fulgurant #element/foudre(4 mana, tour)
>Lance un faisceau électrique qui peut contourner les obstacles pour toucher une cible à couvert. Inflige 3d4[[Glossaire#Jet explosif|!]] dégâts de [[Les types de dégâts#Foudre|foudre]].
>*Vous pouvez viser une case que vous ne voyez pas, mais le MJ ne doit pas vous informer si l'attaque pourrait toucher une cible.*
- #element/foudre(mana, tour)
>
- #element/terre(2 mana, tour)
>Un pilier de matière est extirpé du sol pour aller frapper la cible, qui est alors déplacée d'une case. Si la cible est propulsée contre un mur, elle subit alors 3d12 dégâts [[Les types de dégâts#Contondant|contondant]].
- #element/terre(3 mana, tour)
>Propulse un projectile de matière sur la cible, infligeant 1d12 dégâts [[Les types de dégâts#Contondant|contondant]] et appliquant un [[Les effets#L'étourdissement|étourdissement]] (2/12).
- Bouclier tortue #element/terre(3 mana, tour)
>Vous gagnez un bonus de 2 en blocage, mais subissez également un malus de 2 en esquive et perdez 2 cases de vitesse de course durant 1 min.
- #element/terre(2 mana, réaction)
> Vous gagnez une résistance aux dégâts [[Les types de dégâts#Les dégâts physiques|physiques]] jusqu'à la fin de votre prochain tour.
- #element/terre(mana, tour)
>
- Enchantement mineur #element/arcane(2 mana, tour, V/Gl)
> Condense de l'énergie magique dans une arme ou un projectile. Vous faites une attaque immédiatement après avoir lancé ce sort sans dépenser d'action, infligeant 1d8 dégâts supplémentaire. Les dégâts de l'arme deviennent [[Les types de dégâts#Neutre|magique]].
- Rupture de force #element/arcane(3 mana, tour, V/Ge/Gl)
> Vous condensez une puissante énergie magique qui est propulsée directement sur votre cible. Vous lancez 2d20 et prenez le plus haut résultat pour infliger des dégâts [[Les types de dégâts#Neutre|magique]]. *Avoir un #avantage aux dégâts permet de lancer un autre d20.* *Augmenter les dégâts de ce sort permet d'infliger 5 dégâts [[Les types de dégâts#Neutre|magique]] supplémentaire.*
- #element/arcane(mana, tour)
>
- #element/arcane(mana, tour)
>
- #element/arcane(mana, tour)
>
- Foulée aérienne #element/air(3 mana, tour, 12 cases)
>La vitesse de course de votre cible augmente de 2 cases pendant 1 minute. Vous gagnez également un bonus de +1 à l'esquive.
- Pression forcée #element/air(5 mana, tour, 18 cases)
>Crée une imposante colonne d'air descendent de 3 cases de rayon sur 12 cases de haut. Les créatures à l'intérieur ont un malus de 1 à l'esquive. Les créatures volantes chutent de 3 cases par tour.
- #element/air(mana, tour)
>
- #element/air(mana, tour)
>
- #element/air(mana, tour)
>
- Conservation #element/nature (2 mana, 1 minute)
>Permet à jusqu'à 5 herbes ou préparations médicinales de se conserver 1 jour de plus. *Ne peux être utilisé qu'une seule fois par herbe/préparation.*
- #element/nature(mana, tour)
>
- #element/nature(mana, tour)
>
- #element/nature(mana, tour)
>
- #element/nature(mana, tour)
>
- Absorption radieuse #element/lumiere (3 mana, tour)
> Absorbe la lumière d'une zone de 4 cases de rayon, la faisant apparaitre comme plus sombre. #todo
- #element/lumiere (mana, tour)
>
- #element/lumiere (mana, tour)
>
- #element/lumiere (mana, tour)
>
- #element/lumiere (mana, tour)
>
- #element/psy(6 mana, tour)
>Envenime l'esprit de la cible, brouillant sa perception de la réalité et lui faisant voir des images subliminales de chaos. Applique un effet de [[Les effets#La peur|peur]] (4/12).
### Rang 2
- Trait de feu 2 #element/feu (5 mana, tour, 15 cases, V/Ge/Gl)
>Tire un faisceau de flamme, infligeant 3d8 de dégâts de feu.
- Lames de glace 2 #element/glace (5 mana, tour, 15 cases)
>Tire 3 projectiles à 1d8 de glace. Augmenter les dés de dégâts offre un projectile supplémentaire à la place. Chaque projectile demande un jet d'attaque séparé et peut viser une cible différente.
- Chaine de foudre 2 #element/foudre (5 mana, tour, 15 cases)
>Frappe une cible visible puis rebondit sur jusqu'à 3 autres cibles à 2 cases de la première. 1d8+3 de foudre.
- Décharge de foudre 2 #element/foudre(3 mana, tour)
>Tire une décharge foudroyante d'énergie, infligeant 6d4[[Glossaire#Jet explosif|!]] de dégâts de foudre.
- Conservation 2 #element/nature (4 mana, 1 minute)
>Permet à jusqu'à 8 herbes ou préparations médicinales de se conserver 3 jours de plus. *Ne peux être utilisé qu'une seule fois par herbe/préparation.*
- Boule de feu #element/feu (8 mana, tour, 12 cases)
>Projette une imposante boule de flamme explosant au contact d'une surface, infligeant ainsi 4d10 de feu sur 3 cases de rayon.
- Détonation #element/feu (4 mana, tour, 8 cases)
>Pointe un lieu visible. Une explosion de flamme jaillit subitement, infligeant 2d10 de feu sur 2 cases de rayon.
- Lance de givre #element/glace(4 mana, tour)
>Une lame de glace vient grandir le long de votre arme. Augmente votre portée d'une case. L'arme inflige des dégâts tranchants. Dure 1 min, casse après 8 coups réussis.
- Téléportation #element/foudre (4 mana, tour)
>Se téléporte à un point visible à 9 cases max.
- Apaisement #element/psy (3 mana, tour)
>En touchant la cible, vous pouvez faire un jet d'intelligence. Guérit l'influence, le charme et la peur, mais augmente les chances de ces effet de 1 niveau pendant 3 tours.
- Painshock #element/psy (6 mana, tour)
>*Ne fonctionne que si la cible touchée à subit des dégâts depuis votre dernier tour.* Vous touchez une plaie et intensifiez la douleur à l'extrême. Applique un effet d'[[Les effets#L'étourdissement|étourdissement]]. La difficulté est égale à 2/12 + 1 niveau pour chaque 10% de vie max retiré.
- Perturbateur #element/psy (4 mana, réaction, 9 cases, V/Ge)
>Lorsqu'un lanceur de sort termine son incantation, vous pouvez perturber les flux magiques pour lui imposer un malus de 3 au jet.
### Rang 3
- Rejet pur #divin (spécial, tour, 3 cases, Ge)
>Vous propulsez une énergie magique pure condensée sur votre adversaire avec une puissance absolue. Vous infligez 1d6!+4 dégâts [[Les types de dégâts#Neutre|magique]] tous les 3 mana dépensé. Vous pouvez dépenser jusqu'à 30 mana. Après avoir lancé ce sort, vous subissez un malus de 4 au lancer de sort pendant 1 tour.
### Sorts unique
Les sorts uniques sont des sorts obtenus uniquement avec des objets magiques ou en progressant dans l'arbre d'entrainement. Il n'existe **aucun** autre moyen d'obtenir des sorts.
- Dévastation #element/feu + #element/glace + #element/foudre (10 mana, tour, 12 cases)
>Inflige 10+3d10 dégâts. Vous pouvez choisir le type de dégâts entre feu, glace et foudre. Ignore les résistances et réduit les immunités en résistance. ^484fc3
- Soin #element/nature (8 mana, tour, toucher)
>Soigne 10+1d10 PV et guérit l'[[Les effets#L'étourdissement|étourdissement]], le [[Les effets#Le saignement|saignement]] et les [[Les effets#L'empoisonnement|poisons]]. ^068b55
- Contresort #element/arcane (4 mana, réaction, 12 cases)
>Perturbe les flux magique pour interrompre une canalisation en cours. Vous pouvez augmenter le coût du sort pour augmenter les chances de réussite. La difficulté est égale à 6 - le cout du sort à interrompre + le cout du contresort. ^a8f46f
- Focalisation destructrice #element/arcane (12 mana, tour)
>Vous focalisez les énergies magiques sur vous, rendant l'utilisation de sort plus complexe pour les autres. La densité d'énergie anormale vous fait subir 5 points de dégâts par tour. Pendant une minute, toute personne à 18 cases de vous essayant de lancer un sort ou de [[1.Règles/6.Les Aspects/index#Transformations|se transformer]] subit un malus de 4. ^73b8bd
- Domination mentale #element/psy (10 mana, tour, toucher)
>Applique un effet de [[Les effets#La possession|possession]] (6/12). ^5b38b6
### Sorts spéciaux
Les sorts spéciaux sont une liste de sorts que les joueurs peuvent obtenir durant certaines aventures. Selon les cas, un joueur peut demander au maitre du jeu de commencer avec un sort spécial si ça correspond à son passé. Les sorts spéciaux peuvent aussi être des sorts que les PNJ ont et qu'ils peuvent apprendre aux joueurs.`);
</script>

22
pages/editor.vue Normal file
View 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>

View File

@ -0,0 +1,49 @@
<template>
<div v-if="status === 'pending'" class="flex">
<Head>
<Title>d[any] - Chargement</Title>
</Head>
<Loading />
</div>
<div class="flex flex-1 justify-start items-start" v-else-if="page">
<Head>
<Title>d[any] - {{ page.title }}</Title>
</Head>
<template v-if="page.type === 'markdown'">
<div class="flex flex-1 justify-start items-start flex-col xl:px-24 md:px-8 px-4 py-6">
<div class="flex flex-1 flex-row justify-between items-center">
<ProseH1>{{ page.title }}</ProseH1>
<NuxtLink :href="{ name: 'explore-edit-path', params: { path: path } }"><Button v-if="isOwner">Modifier</Button></NuxtLink>
</div>
<Markdown :content="page.content" />
</div>
</template>
<template v-else-if="page.type === 'canvas'">
<Canvas :canvas="JSON.parse(page.content)" />
</template>
<template v-else>
<ProseH2 class="flex-1 text-center">Impossible d'afficher le contenu demandé</ProseH2>
</template>
</div>
<div v-else-if="status === 'error'">
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<span>{{ error?.message }}</span>
</div>
<div v-else>
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<div><ProseH2>Impossible d'afficher le contenu demandé</ProseH2></div>
</div>
</template>
<script setup lang="ts">
const route = useRouter().currentRoute;
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
const { loggedIn, user } = useUserSession();
const { data: page, status, error } = await useFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [route, path], });
const isOwner = computed(() => user.value?.id === page.value?.owner);
</script>

View File

@ -0,0 +1,84 @@
<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="page.content" /></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">
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 ]});
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>

Some files were not shown because too many files have changed in this diff Show More