Compare commits

..

No commits in common. "f22e63bd4d28cf29de5fa58a4e07decf62bc82ec" and "1c239f161b0a638d0c2963c0746339b911c09519" have entirely different histories.

37 changed files with 234 additions and 792 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -1,16 +1,13 @@
<template> <template>
<template v-for="(item, idx) of options"> <template v-for="(item, idx) of options">
<template v-if="item.type === 'item'"> <template v-if="item.type === 'item'">
<DropdownMenuItem :disabled="item.disabled" :textValue="item.label" @select="item.select" :class="{'!pe-1': item.kbd}" class="group cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35"> <DropdownMenuItem :disabled="item.disabled" :textValue="item.label" @select="item.select" :class="{'pe-1': item.kbd}" class="group cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<Icon v-if="item.icon" :icon="item.icon" class="absolute left-1.5" /> <Icon v-if="item.icon" :icon="item.icon" class="absolute left-1.5" />
<div class="flex flex-1 justify-between">
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
<span v-if="item.kbd" class="mx-2 text-xs font-mono text-light-70 dark:text-dark-70 relative top-0.5"> {{ item.kbd }} </span> <span v-if="item.kbd" class="mx-2 text-xs font-mono text-light-70 dark:text-dark-70 relative top-0.5"> {{ item.kbd }} </span>
</div>
</DropdownMenuItem> </DropdownMenuItem>
</template> </template>
<!-- TODO -->
<template v-else-if="item.type === 'checkbox'"> <template v-else-if="item.type === 'checkbox'">
<DropdownMenuCheckboxItem :disabled="item.disabled" :textValue="item.label" @update:checked="item.select"> <DropdownMenuCheckboxItem :disabled="item.disabled" :textValue="item.label" @update:checked="item.select">
<DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
@ -21,7 +18,6 @@
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
</template> </template>
<!-- TODO -->
<template v-if="item.type === 'radio'"> <template v-if="item.type === 'radio'">
<DropdownMenuLabel>{{ item.label }}</DropdownMenuLabel> <DropdownMenuLabel>{{ item.label }}</DropdownMenuLabel>
<DropdownMenuRadioGroup @update:model-value="item.change"> <DropdownMenuRadioGroup @update:model-value="item.change">

View File

@ -1,6 +1,6 @@
<template> <template>
<Label class="my-2 flex flex-1 items-center justify-between flex-col md:flex-row"> <Label class="py-4 flex flex-row justify-center items-center">
<span class="pb-1 md:p-0">{{ label }}</span> <span>{{ label }}</span>
<SelectRoot v-model="model"> <SelectRoot v-model="model">
<SelectTrigger :disabled="disabled" class="mx-4 inline-flex min-w-[160px] items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1 <SelectTrigger :disabled="disabled" class="mx-4 inline-flex min-w-[160px] items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1
bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none data-[placeholder]:font-normal bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none data-[placeholder]:font-normal

View File

@ -5,7 +5,7 @@
class="mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 class="mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20" border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20"
:type="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs" @change="(e) => emits('change', e)" @input="(e) => emits('input', e)"> :type="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs">
</Label> </Label>
</template> </template>
@ -16,10 +16,5 @@ const { type = 'text', label, disabled = false, placeholder } = defineProps<{
disabled?: boolean disabled?: boolean
placeholder?: string placeholder?: string
}>(); }>();
const emits = defineEmits<{
change: [Event]
input: [Event]
}>();
const model = defineModel<string>(); const model = defineModel<string>();
</script> </script>

View File

@ -1,20 +1,56 @@
<template> <template>
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm" :items="model" :get-key="getKey" :defaultExpanded="flatten(model)"> <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" :defaultExpanded="flatten(model)">
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level - 0.5}em` }" v-bind="item.bind" class="flex items-center px-2 outline-none relative cursor-pointer"> <TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level - 0.5}em` }" v-bind="item.bind" class="flex items-center px-2 outline-none relative cursor-pointer">
<slot :isExpanded="isExpanded" :item="item" /> <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> </TreeItem>
</TreeRoot> </TreeRoot>
</template> </template>
<script setup lang="ts" generic="T extends Record<string, any>"> <script setup lang="ts">
const { getKey } = defineProps<{ import { Icon } from '@iconify/vue/dist/iconify.js';
getKey: (val: T) => string
}>();
const model = defineModel<T[]>(); interface TreeItem
function flatten(arr: T[]): string[]
{ {
return arr.filter(e => e.open).flatMap(e => [getKey(e), ...flatten(e.children ?? [])]); label: string
link?: string
tag?: string
open?: boolean
children?: TreeItem[]
}
const model = defineModel<TreeItem[]>();
function flatten(arr: TreeItem[]): string[]
{
return arr.filter(e => e.open).flatMap(e => [e?.link ?? e.label, ...flatten(e.children ?? [])]);
} }
</script> </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

@ -31,8 +31,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { parseURL } from 'ufo'; import { parseURL } from 'ufo';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import { iconByType } from '#shared/general.utils';
const iconByType: Record<string, string> = {
'folder': 'circum:folder-on',
'canvas': 'ph:graph-light',
'file': 'radix-icons:file',
}
const { href } = defineProps<{ const { href } = defineProps<{
href: string href: string
class?: string class?: string

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,7 +8,7 @@
<Icon icon="si:error-line" class="w-12 h-12 text-light-60 dark:text-dark-60"/> <Icon icon="si:error-line" class="w-12 h-12 text-light-60 dark:text-dark-60"/>
<div class="text-3xl">Une erreur est survenue.</div> <div class="text-3xl">Une erreur est survenue.</div>
</div> </div>
<pre class="text-center text-wrap">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre> <pre class="">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
<Button @click="handleError">Revenir en lieu sûr</Button> <Button @click="handleError">Revenir en lieu sûr</Button>
</div> </div>
</template> </template>

View File

@ -76,21 +76,11 @@
<NuxtLink :href="{ name: 'explore' }" no-prefetch class="flex flex-1 font-bold text-lg items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" active-class="text-accent-blue border-s-2 !border-accent-blue"> <NuxtLink :href="{ name: 'explore' }" no-prefetch class="flex flex-1 font-bold text-lg items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" active-class="text-accent-blue border-s-2 !border-accent-blue">
<div class="pl-3 py-1 flex-1 truncate">Projet</div> <div class="pl-3 py-1 flex-1 truncate">Projet</div>
</NuxtLink> </NuxtLink>
<Tree v-if="pages" v-model="pages" :getKey="(item) => item.path"> <Tree v-if="pages" v-model="pages"/>
<template #default="{ item, isExpanded }">
<NuxtLink :href="item.value.path && !item.hasChildren ? { name: 'explore-path', params: { path: item.value.path } } : undefined" no-prefetch class="flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple" :class="{ 'font-medium': item.hasChildren }" active-class="text-accent-blue" :data-private="item.value.private">
<Icon v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level - 1}em` }" />
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="w-5 h-5" />
<div class="pl-3 py-1 flex-1 truncate">
{{ item.value.title }}
</div>
<Tooltip message="Privé" side="right"><Icon v-show="item.value.private" icon="radix-icons:lock-closed" /></Tooltip>
</NuxtLink>
</template>
</Tree>
</div> </div>
<div class="xl:px-12 px-6 text-start text-xs text-light-60 dark:text-dark-60 relative top-4"> <div class="xl:px-12 px-6 text-start text-xs text-light-60 dark:text-dark-60 relative top-4">
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink> <NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> -
<NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
<p>Copyright Peaceultime - 2024</p> <p>Copyright Peaceultime - 2024</p>
</div> </div>
</div> </div>
@ -103,7 +93,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import type { NavigationTreeItem } from '~/server/api/navigation.get'; import type { NavigationTreeItem } from '~/server/api/navigation.get';
import { iconByType } from '#shared/general.utils';
const open = ref(false); const open = ref(false);
const { loggedIn, clear } = useUserSession(); const { loggedIn, clear } = useUserSession();
@ -120,8 +109,8 @@ const { data: pages } = await useLazyFetch('/api/navigation', {
watch: [useRouter().currentRoute] watch: [useRouter().currentRoute]
}); });
function transform(list: NavigationTreeItem[] | undefined): NavigationTreeItem[] | undefined function transform(list: NavigationTreeItem[] | undefined): any[] | undefined
{ {
return list?.map(e => ({ ...e, open: path.value?.startsWith(e.path), children: transform(e.children) })); return list?.map(e => ({ label: e.title, children: transform(e?.children ?? undefined), link: e.path, tag: e.private ? 'private' : e.type, open: path.value?.startsWith(e.path)}))
} }
</script> </script>

View File

@ -1,7 +1,4 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
import vuePlugin from 'rollup-plugin-vue'
import postcssPlugin from 'rollup-plugin-postcss'
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2024-04-03', compatibilityDate: '2024-04-03',
modules: [ modules: [
@ -117,42 +114,17 @@ export default defineNuxtConfig({
path: '~/components', path: '~/components',
pathPrefix: false, pathPrefix: false,
}, },
{
path: '~/server/components',
pathPrefix: true,
global: true,
},
], ],
nitro: { nitro: {
alias: {
'public': '//public',
},
publicAssets: [{
baseURL: 'public',
dir: 'public',
}],
preset: 'bun',
experimental: { experimental: {
tasks: true, tasks: true,
}, },
rollupConfig: {
plugins: [
vuePlugin({ include: /\.vue$/, target: 'node' })
]
},
}, },
runtimeConfig: { runtimeConfig: {
session: { session: {
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013' password: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
}, },
database: 'db.sqlite', database: 'db.sqlite'
mail: {
host: '',
port: '',
user: '',
passwd: '',
dkim: '',
}
}, },
security: { security: {
rateLimiter: false, rateLimiter: false,

View File

@ -20,7 +20,6 @@
"hast": "^1.0.0", "hast": "^1.0.0",
"lodash.capitalize": "^4.2.1", "lodash.capitalize": "^4.2.1",
"mdast-util-find-and-replace": "^3.0.1", "mdast-util-find-and-replace": "^3.0.1",
"nodemailer": "^6.9.16",
"nuxt": "^3.14.159", "nuxt": "^3.14.159",
"nuxt-security": "^2.0.0", "nuxt-security": "^2.0.0",
"radix-vue": "^1.9.8", "radix-vue": "^1.9.8",
@ -30,8 +29,6 @@
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1", "remark-rehype": "^11.1.1",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-vue": "^6.0.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"vue": "latest", "vue": "latest",
@ -41,7 +38,6 @@
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.12", "@types/bun": "^1.1.12",
"@types/lodash.capitalize": "^4.2.9", "@types/lodash.capitalize": "^4.2.9",
"@types/nodemailer": "^6.4.16",
"@types/unist": "^3.0.3", "@types/unist": "^3.0.3",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",
"bun-types": "^1.1.34", "bun-types": "^1.1.34",

View File

@ -1,54 +1,27 @@
<script lang="ts">
const mailSchema = z.object({
to: z.string().email(),
template: z.string(),
data: z.string(),
});
const schemaList: Record<string, z.ZodObject<any> | null> = {
'pull': null,
'push': null,
'mail': mailSchema,
}
</script>
<script setup lang="ts"> <script setup lang="ts">
import { z } from 'zod';
definePageMeta({ definePageMeta({
rights: ['admin'], rights: ['admin'],
}) })
const job = ref<string>(''); const job = ref<string>('');
const toaster = useToast(); const toaster = useToast();
const payload = reactive<Record<string, any>>({ const data = ref(), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), success = ref(false), err = ref(false), error = ref();
data: JSON.stringify({ username: "Peaceultime", id: 1, timestamp: Date.now() }),
to: 'clem31470@gmail.com',
});
const data = ref(), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), success = ref(false), error = ref<Error | null>();
async function fetch() async function fetch()
{ {
status.value = 'pending'; status.value = 'pending';
data.value = null; data.value = null;
error.value = null; error.value = null;
err.value = false;
success.value = false; success.value = false;
try try
{ {
const schema = schemaList[job.value];
if(schema)
{
console.log(payload);
const parsedPayload = schema.parse(payload);
}
data.value = await $fetch(`/api/admin/jobs/${job.value}`, { data.value = await $fetch(`/api/admin/jobs/${job.value}`, {
method: 'POST', method: 'POST',
body: payload,
}); });
status.value = 'success'; status.value = 'success';
error.value = null; error.value = null;
err.value = false;
success.value = true; success.value = true;
toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, }); toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, });
@ -56,10 +29,11 @@ async function fetch()
catch(e) catch(e)
{ {
status.value = 'error'; status.value = 'error';
error.value = e as Error; error.value = e;
err.value = true;
success.value = false; success.value = false;
toaster.add({ duration: 10000, content: error.value.message, type: 'error', timer: true, }); toaster.add({ duration: 10000, content: error.value, type: 'error', timer: true, });
} }
} }
</script> </script>
@ -68,20 +42,12 @@ async function fetch()
<Head> <Head>
<Title>d[any] - Administration</Title> <Title>d[any] - Administration</Title>
</Head> </Head>
<div class="flex flex-col justify-start items-center"> <div class="flex flex-col justify-start">
<ProseH2>Administration</ProseH2> <ProseH2>Administration</ProseH2>
<div class="flex flex-row w-full gap-8">
<Select label="Job" v-model="job"> <Select label="Job" v-model="job">
<SelectItem label="Récupérer les données d'Obsidian" value="pull" /> <SelectItem label="Récupérer les données d'Obsidian" value="pull" />
<SelectItem label="Envoyer les données dans Obsidian" value="push" disabled /> <SelectItem label="Envoyer les données dans Obsidian" value="push" disabled />
<SelectItem label="Envoyer un mail de test" value="mail" />
</Select> </Select>
<Select v-if="job === 'mail'" v-model="payload.template" label="Modèle" class="w-full" ><SelectItem label="Inscription" value="registration" /></Select>
</div>
<div v-if="job === 'mail'" class="flex justify-center items-center flex-col">
<TextInput label="Destinataire" class="w-full" v-model="payload.to" />
<textarea v-model="payload.data" class="w-[640px] bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none m-2 px-2"></textarea>
</div>
<Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'"> <Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'">
<span>Executer</span> <span>Executer</span>
</Button> </Button>

View File

@ -3,28 +3,16 @@
<Title>d[any] - Configuration du projet</Title> <Title>d[any] - Configuration du projet</Title>
</Head> </Head>
<div class="flex flex-1 flex-row gap-4 p-6 items-start" v-if="navigation"> <div class="flex flex-1 flex-row gap-4 p-6 items-start" v-if="navigation">
<div class="flex flex-1 flex-col w-[450px] max-w-[450px] max-h-full"> <DraggableTree class="list-none select-none border border-light-35 dark:border-dark-35 text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm overflow-auto w-[450px] max-h-full"
<DraggableTree class="list-none select-none border border-light-35 dark:border-dark-35 text-light-100 dark:text-dark-100 p-2 xl:text-base text-sm overflow-auto" :items="navigation ?? undefined" :get-key="(item: Partial<ProjectItem>) => item.path ? getPath(item as ProjectItem) : ''" @updateTree="drop">
:items="navigation ?? undefined" :get-key="(item: Partial<ProjectExtendedItem>) => item.path !== undefined ? getPath(item as ProjectExtendedItem) : ''" @updateTree="drop"> <template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver, item }">
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, item }"> <div class="flex flex-1 px-2" :class="{ 'opacity-50': isDragging }" :style="{ 'padding-left': `${item.level - 0.5}em` }">
<div class="flex flex-1 items-center px-2" :class="{ 'opacity-50': isDragging }" :style="{ 'padding-left': `${item.level - 0.5}em` }">
<span class="py-2 px-2" @click="handleToggle" v-if="item.hasChildren" > <span class="py-2 px-2" @click="handleToggle" v-if="item.hasChildren" >
<Icon :icon="isExpanded ? 'lucide:folder-open' : 'lucide:folder'"/> <Icon :icon="isExpanded ? 'lucide:folder-open' : 'lucide:folder'"/>
</span> </span>
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="group-[:hover]:text-accent-purple mx-2" @click="() => { handleSelect(); selected = isSelected ? undefined : item.value; }" /> <div class="ps-2 py-1 flex-1 truncate" :class="{'!ps-4 border-s border-light-35 dark:border-dark-35': !item.hasChildren}" :title="item.value.title" @click="() => { handleSelect(); selected = isSelected ? undefined : item.value; }">
<div class="pl-3 py-1 flex-1 truncate" :title="item.value.title" @click="() => { handleSelect(); selected = isSelected ? undefined : item.value; }">
{{ item.value.title }} {{ item.value.title }}
</div> </div>
<div class="flex gap-2">
<span @click="item.value.private = !item.value.private">
<Icon v-if="item.value.private" icon="radix-icons:lock-closed" />
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:lock-open-2" />
</span>
<span @click="item.value.navigable = !item.value.navigable">
<Icon v-if="item.value.navigable" icon="radix-icons:eye-open" />
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:eye-none" />
</span>
</div>
</div> </div>
</template> </template>
<template #hint="{ instruction }"> <template #hint="{ instruction }">
@ -37,69 +25,18 @@
}"></div> }"></div>
</template> </template>
</DraggableTree> </DraggableTree>
</div>
<div class="flex flex-col flex-1"> <div class="flex flex-col flex-1">
<div class="flex self-end gap-4 px-4"> <div class="flex self-end gap-4 px-4">
<DropdownMenu align="center" side="bottom" :options="[{
type: 'item',
label: 'Markdown',
kbd: 'Ctrl+N',
icon: 'radix-icons:file',
select: () => add('markdown'),
}, {
type: 'item',
label: 'Dossier',
kbd: 'Ctrl+Shift+N',
icon: 'lucide:folder',
select: () => add('folder'),
}, {
type: 'item',
label: 'Canvas',
icon: 'ph:graph-light',
select: () => add('canvas'),
}, {
type: 'item',
label: 'Fichier',
icon: 'radix-icons:file-text',
select: () => add('file'),
}]">
<Button>Nouveau</Button>
</DropdownMenu>
<Button @click="navigation = tree.remove(navigation, getPath(selected))" v-if="selected" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer</Button>
<Tooltip message="Ctrl+S" side="bottom"><Button @click="() => save(true)" :loading="saveStatus === 'pending'" class="border-light-blue dark:border-dark-blue hover:border-light-blue dark:hover:border-dark-blue focus:shadow-light-blue dark:focus:shadow-dark-blue">Enregistrer</Button></Tooltip> <Tooltip message="Ctrl+S" side="bottom"><Button @click="() => save(true)" :loading="saveStatus === 'pending'" class="border-light-blue dark:border-dark-blue hover:border-light-blue dark:hover:border-dark-blue focus:shadow-light-blue dark:focus:shadow-dark-blue">Enregistrer</Button></Tooltip>
<Tooltip message="Ctrl+Shift+Z" side="bottom"><NuxtLink :href="{ name: 'explore' }"><Button>Annuler</Button></NuxtLink></Tooltip> <Tooltip message="Ctrl+Shift+Z" side="bottom"><NuxtLink :href="{ name: 'explore' }"><Button>Annuler</Button></NuxtLink></Tooltip>
</div> </div>
<div v-if="selected" class="flex-1 flex justify-start items-start"> <div v-if="selected" class="flex-1 flex justify-start items-start">
<div class="flex flex-col flex-1 justify-start items-start"> <div class="flex flex-col flex-1 justify-start items-start">
<input type="text" v-model="selected.title" @change="(e) => { <input type="text" v-model="selected.title" placeholder="Titre" class="flex-1 mx-4 h-16 w-full caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 appearance-none outline-none px-3 py-1 text-5xl font-thin bg-transparent" />
if(selected && !selected.customPath) <span><pre class="ps-2 inline">{{ selected.parent }}/</pre><input v-model="selected.name" placeholder="Titre" class="font-mono border-b border-light-35 dark:border-dark-35 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 bg-transparent" /></span>
{ <div class="flex ms-6 flex-col justify-start items-start">
selected.name = parsePath(selected.title); <Tooltip message="Consultable uniquement par le propriétaire" side="right"><Switch label="Privé" v-model="selected.private" /></Tooltip>
rebuildPath(selected.children, getPath(selected)); <Tooltip message="Afficher dans le menu de navigation" side="right"><Switch label="Navigable" v-model="selected.navigable" /></Tooltip>
}
}" placeholder="Titre" class="flex-1 mx-4 h-16 w-full caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 appearance-none outline-none px-3 py-1 text-5xl font-thin bg-transparent" />
<div class="flex ms-6 flex-col justify-start items-start gap-2">
<div class="flex flex-col justify-start items-start">
<Switch label="Chemin personnalisé" v-model="selected.customPath" />
<span>
<pre v-if="selected.customPath" class="flex items-center">/{{ selected.parent !== '' ? selected.parent + '/' : '' }}<TextInput v-model="selected.name" @input="(e) => {
if(selected && selected.customPath)
{
selected.name = parsePath(selected.name);
rebuildPath(selected.children, getPath(selected));
}
}" class="mx-0"/></pre>
<pre v-else>/{{ getPath(selected) }}</pre>
</span>
</div>
<div class="flex items-center gap-2">
<HoverCard class="!py-2 !px-4"><Icon icon="radix-icons:question-mark-circled" /><template #content><span class="text-sm italic text-light-60 dark:text-dark-60">Un fichier privé n'est consultable que par le propriétaire du projet. Rendre un dossier privé cache automatiquement son contenu sans avoir à chaque fichier un par un.</span></template></HoverCard>
<Switch label="Privé" v-model="selected.private" />
</div>
<div class="flex items-center gap-2">
<HoverCard class="!py-2 !px-4"><Icon icon="radix-icons:question-mark-circled" /><template #content><span class="text-sm italic text-light-60 dark:text-dark-60">Un fichier navigable est disponible dans le menu de navigation à gauche. Les fichiers non navigable peuvent toujours être utilisés dans des liens.</span></template></HoverCard>
<Switch label="Navigable" v-model="selected.navigable" />
</div>
</div> </div>
</div> </div>
</div> </div>
@ -107,42 +44,26 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item'; import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item';
import { parsePath } from '#shared/general.utils'; import { parsePath } from '#shared/general.utils';
import type { ProjectItem } from '~/schemas/project'; import type { ProjectItem } from '~/schemas/project';
import type { FileType } from '~/schemas/file';
import { iconByType } from '#shared/general.utils';
interface ProjectExtendedItem extends ProjectItem
{
customPath: boolean
children?: ProjectExtendedItem[]
}
interface ProjectExtended
{
items: ProjectExtendedItem[]
}
definePageMeta({ definePageMeta({
rights: ['admin', 'editor'], rights: ['admin', 'editor'],
}); });
const router = useRouter(); const router = useRouter();
const route = router.currentRoute;
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
const toaster = useToast(); const toaster = useToast();
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle'); const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
const { data: project } = await useFetch(`/api/project`, { const { data: project } = await useFetch(`/api/project`);
transform: (project) =>{ const navigation = computed({
if(project)
(project as ProjectExtended).items = transform(project.items)!;
return project as ProjectExtended;
}
});
const navigation = computed<ProjectExtendedItem[] | undefined>({
get: () => project.value?.items, get: () => project.value?.items,
set: (value) => { set: (value) => {
const proj = project.value; const proj = project.value;
@ -153,17 +74,15 @@ const navigation = computed<ProjectExtendedItem[] | undefined>({
project.value = proj; project.value = proj;
} }
}); });
const selected = ref<ProjectExtendedItem>(); const selected = ref<ProjectItem>();
useShortcuts({ useShortcuts({
meta_s: { usingInput: true, handler: () => save(false) }, meta_s: { usingInput: true, handler: () => save(false) },
meta_n: { usingInput: true, handler: () => add('markdown') }, meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore-path', params: { path: path.value }}) }
meta_shift_n: { usingInput: true, handler: () => add('folder') },
meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore' }) }
}) })
const tree = { const tree = {
remove(data: ProjectExtendedItem[], id: string): ProjectExtendedItem[] { remove(data: ProjectItem[], id: string): ProjectItem[] {
return data return data
.filter(item => getPath(item) !== id) .filter(item => getPath(item) !== id)
.map((item) => { .map((item) => {
@ -176,7 +95,7 @@ const tree = {
return item; return item;
}); });
}, },
insertBefore(data: ProjectExtendedItem[], targetId: string, newItem: ProjectExtendedItem): ProjectExtendedItem[] { insertBefore(data: ProjectItem[], targetId: string, newItem: ProjectItem): ProjectItem[] {
return data.flatMap((item) => { return data.flatMap((item) => {
if (getPath(item) === targetId) if (getPath(item) === targetId)
return [newItem, item]; return [newItem, item];
@ -190,7 +109,7 @@ const tree = {
return item; return item;
}); });
}, },
insertAfter(data: ProjectExtendedItem[], targetId: string, newItem: ProjectExtendedItem): ProjectExtendedItem[] { insertAfter(data: ProjectItem[], targetId: string, newItem: ProjectItem): ProjectItem[] {
return data.flatMap((item) => { return data.flatMap((item) => {
if (getPath(item) === targetId) if (getPath(item) === targetId)
return [item, newItem]; return [item, newItem];
@ -205,7 +124,7 @@ const tree = {
return item; return item;
}); });
}, },
insertChild(data: ProjectExtendedItem[], targetId: string, newItem: ProjectExtendedItem): ProjectExtendedItem[] { insertChild(data: ProjectItem[], targetId: string, newItem: ProjectItem): ProjectItem[] {
return data.flatMap((item) => { return data.flatMap((item) => {
if (getPath(item) === targetId) { if (getPath(item) === targetId) {
// already a parent: add as first child // already a parent: add as first child
@ -226,7 +145,7 @@ const tree = {
}; };
}); });
}, },
find(data: ProjectExtendedItem[], itemId: string): ProjectExtendedItem | undefined { find(data: ProjectItem[], itemId: string): ProjectItem | undefined {
for (const item of data) { for (const item of data) {
if (getPath(item) === itemId) if (getPath(item) === itemId)
return item; return item;
@ -238,27 +157,12 @@ const tree = {
} }
} }
}, },
search(data: ProjectExtendedItem[], prop: keyof ProjectExtendedItem, value: string): ProjectExtendedItem[] {
const arr = [];
for (const item of data)
{
if (item[prop]?.toString().toLowerCase()?.startsWith(value.toLowerCase()))
arr.push(item);
if (tree.hasChildren(item)) {
arr.push(...tree.search(item.children ?? [], prop, value));
}
}
return arr;
},
getPathToItem({ getPathToItem({
current, current,
targetId, targetId,
parentIds = [], parentIds = [],
}: { }: {
current: ProjectExtendedItem[] current: ProjectItem[]
targetId: string targetId: string
parentIds?: string[] parentIds?: string[]
}): string[] | undefined { }): string[] | undefined {
@ -275,37 +179,12 @@ const tree = {
return nested; return nested;
} }
}, },
hasChildren(item: ProjectExtendedItem): boolean { hasChildren(item: ProjectItem): boolean {
return (item.children ?? []).length > 0; return (item.children ?? []).length > 0;
}, },
} }
function add(type: FileType): void function updateTree(instruction: Instruction, itemId: string, targetId: string) : ProjectItem[] | undefined {
{
if(!navigation.value)
{
return;
}
const news = [...tree.search(navigation.value, 'title', 'Nouveau')].filter((e, i, a) => a.indexOf(e) === i);
const title = `Nouveau${news.length > 0 ? ' (' + news.length +')' : ''}`;
const item: ProjectExtendedItem = { navigable: true, private: false, parent: '', path: '', title: title, name: parsePath(title), type: type, order: 0, children: type === 'folder' ? [] : undefined, customPath: false };
if(!selected.value)
{
navigation.value = [...navigation.value, item];
}
else if(selected.value?.children)
{
item.parent = getPath(selected.value);
navigation.value = tree.insertChild(navigation.value, item.parent, item);
}
else
{
navigation.value = tree.insertAfter(navigation.value, getPath(selected.value), item);
}
}
function updateTree(instruction: Instruction, itemId: string, targetId: string) : ProjectExtendedItem[] | undefined {
if(!navigation.value) if(!navigation.value)
return; return;
@ -359,16 +238,14 @@ function updateTree(instruction: Instruction, itemId: string, targetId: string)
return navigation.value; return navigation.value;
} }
function transform(items: ProjectItem[] | undefined): ProjectExtendedItem[] | undefined
{
return items?.map(e => ({...e, customPath: e.name !== parsePath(e.title), children: transform(e.children)}));
}
function drop(instruction: Instruction, itemId: string, targetId: string) function drop(instruction: Instruction, itemId: string, targetId: string)
{ {
navigation.value = updateTree(instruction, itemId, targetId) ?? navigation.value ?? []; navigation.value = updateTree(instruction, itemId, targetId) ?? navigation.value ?? [];
} }
function rebuildPath(tree: ProjectExtendedItem[] | null | undefined, parentPath: string) function rebuildPath(tree: ProjectItem[] | null | undefined, parentPath: string)
{ {
debugger;
if(!tree) if(!tree)
return; return;
@ -392,7 +269,7 @@ async function save(redirect: boolean): Promise<void>
type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000 type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000
}); });
if(redirect) router.push({ name: 'explore' }); if(redirect) router.push({ name: 'explore-path', params: { path: path.value } });
} catch(e: any) { } catch(e: any) {
toaster.add({ toaster.add({
type: 'error', content: e.message, timer: true, duration: 10000 type: 'error', content: e.message, timer: true, duration: 10000
@ -400,8 +277,8 @@ async function save(redirect: boolean): Promise<void>
saveStatus.value = 'error'; saveStatus.value = 'error';
} }
} }
function getPath(item: ProjectExtendedItem): string function getPath(item: ProjectItem): string
{ {
return [item.parent, parsePath(item.customPath ? item.name : item.title)].filter(e => !!e).join('/'); return [item.parent, parsePath(item?.name ?? item.title)].filter(e => !!e).join('/');
} }
</script> </script>

View File

@ -35,7 +35,7 @@
</div> </div>
<div class="flex flex-col gap-2 justify-start"> <div class="flex flex-col gap-2 justify-start">
<ProseH3>Utilisateur</ProseH3> <ProseH3>Utilisateur</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Validation du compte par mail<ProseTag>prioritaire</ProseTag></span></Label> <Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Validation du compte par mail<ProseTag>prioritaire</ProseTag></span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Modification de profil</span></Label> <Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Modification de profil</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Image de profil</span></Label> <Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Image de profil</span></Label>
</div> </div>

View File

@ -1,18 +0,0 @@
<template>
<Head>
<Title>d[any] - Validation de votre adresse mail</Title>
</Head>
<div class="flex flex-col justify-center items-center">
<ProseH2>Votre compte a été validé ! 🎉</ProseH2>
<div class="flex flex-row gap-8">
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'user-login', replace: true }">Se connecter</NuxtLink></Button>
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'index', replace: true }">Retourner à l'accueil</NuxtLink></Button>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'login'
})
</script>

View File

@ -4,26 +4,13 @@ import { hasPermissions } from "#shared/auth.util";
definePageMeta({ definePageMeta({
guestsGoesTo: '/user/login', guestsGoesTo: '/user/login',
}) })
const { user, clear } = useUserSession(); let { user, clear } = useUserSession();
const toaster = useToast();
const loading = ref<boolean>(false);
async function revalidateUser()
{
loading.value = true;
await $fetch(`/api/users/${user.value?.id}/revalidate`, {
method: 'get'
});
loading.value = false;
toaster.add({ closeable: false, duration: 10000, timer: true, content: 'Un mail vous a été envoyé.', type: 'info' });
}
async function deleteUser() async function deleteUser()
{ {
loading.value = true;
await $fetch(`/api/users/${user.value?.id}`, { await $fetch(`/api/users/${user.value?.id}`, {
method: 'delete' method: 'delete'
}); });
loading.value = false;
clear(); clear();
} }
</script> </script>
@ -49,14 +36,14 @@ async function deleteUser()
<template v-slot:content><span>Tant que votre adresse mail n'as pas été validée, vous n'avez que <template v-slot:content><span>Tant que votre adresse mail n'as pas été validée, vous n'avez que
des droits de lecture.</span></template> des droits de lecture.</span></template>
</HoverCard> </HoverCard>
<Button class="ms-4" @click="revalidateUser" :loading="loading">Renvoyez un mail</Button> <Tooltip message="En cours de développement"><Button class="ms-4" disabled>Renvoyez un mail</Button></Tooltip>
</div> </div>
</div> </div>
<div class="flex flex-col self-center flex-1 gap-4"> <div class="flex flex-col self-center flex-1 gap-4">
<Button @click="clear">Se deconnecter</Button> <Button @click="async () => await clear()">Se deconnecter</Button>
<Button disabled><Tooltip message="En cours de développement">Modifier mon profil</Tooltip></Button> <Button disabled><Tooltip message="En cours de développement">Modifier mon profil</Tooltip></Button>
<AlertDialogRoot> <AlertDialogRoot>
<AlertDialogTrigger asChild><Button :loading="loading" <AlertDialogTrigger asChild><Button
class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer
mon compte</Button></AlertDialogTrigger> mon compte</Button></AlertDialogTrigger>
<AlertDialogPortal> <AlertDialogPortal>

View File

@ -11,13 +11,19 @@
<TextInput type="text" label="Nom d'utilisateur" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/> <TextInput type="text" label="Nom d'utilisateur" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
<TextInput type="email" label="Email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/> <TextInput type="email" label="Email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/>
<TextInput type="password" label="Mot de passe" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/> <TextInput type="password" label="Mot de passe" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/>
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none"> <div class="flex flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
<span class="col-span-2">Prérequis de sécurité</span> <span class="">Votre mot de passe doit respecter les critères de sécurité suivants
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span> :</span>
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLower}"><Icon v-show="!checkedLower" icon="radix-icons:cross-2" />Une minuscule</span> <span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedLength}">Entre 8 et 128
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedUpper}"><Icon v-show="!checkedUpper" icon="radix-icons:cross-2" />Une majuscule</span> caractères</span>
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedDigit}"><Icon v-show="!checkedDigit" icon="radix-icons:cross-2" />Un chiffre</span> <span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedLowerUpper}">Au moins
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span> une minuscule et une majuscule</span>
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedDigit}">Au moins un
chiffre</span>
<span class="ps-4" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}">Au moins un
caractère spécial parmi la liste suivante:
<pre class="text-wrap">! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ ] ^ _ ` { | } ~</pre>
</span>
</div> </div>
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/> <TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
<Button class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button> <Button class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
@ -46,8 +52,7 @@ const { add: addToast, clear: clearToasts } = useToast();
const confirmPassword = ref(""); const confirmPassword = ref("");
const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128); const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128);
const checkedLower = computed(() => state.password.toUpperCase() !== state.password); const checkedLowerUpper = computed(() => state.password.toLowerCase() !== state.password && state.password.toUpperCase() !== state.password);
const checkedUpper = computed(() => state.password.toLowerCase() !== state.password);
const checkedDigit = computed(() => /[0-9]/.test(state.password)); const checkedDigit = computed(() => /[0-9]/.test(state.password));
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e))); const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@ -1,16 +1,14 @@
import { z } from "zod"; import { z } from "zod";
export const fileType = z.enum(['folder', 'file', 'markdown', 'canvas']);
export const schema = z.object({ export const schema = z.object({
path: z.string(), path: z.string(),
owner: z.number().finite(), owner: z.number().finite(),
title: z.string(), title: z.string(),
type: fileType, type: z.enum(['folder', 'file', 'markdown', 'canvas']),
content: z.string(), content: z.string(),
navigable: z.boolean(), navigable: z.boolean(),
private: z.boolean(), private: z.boolean(),
order: z.number().finite(), order: z.number().finite(),
}); });
export type FileType = z.infer<typeof fileType>;
export type File = z.infer<typeof schema>; export type File = z.infer<typeof schema>;

View File

@ -1,11 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { fileType } from "./file";
export const single = z.object({ export const single = z.object({
path: z.string(), path: z.string(),
owner: z.number().finite(), owner: z.number().finite(),
title: z.string(), title: z.string(),
type: fileType, type: z.enum(['folder', 'file', 'markdown', 'canvas']),
navigable: z.boolean(), navigable: z.boolean(),
private: z.boolean(), private: z.boolean(),
order: z.number().finite(), order: z.number().finite(),

View File

@ -1,12 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { fileType } from "./file";
const baseItem = z.object({ const baseItem = z.object({
path: z.string(), path: z.string(),
parent: z.string(), parent: z.string(),
name: z.string(), name: z.string().optional(),
title: z.string(), title: z.string(),
type: fileType, type: z.enum(['folder', 'file', 'markdown', 'canvas']),
navigable: z.boolean(), navigable: z.boolean(),
private: z.boolean(), private: z.boolean(),
order: z.number().finite(), order: z.number().finite(),

View File

@ -1,13 +1,5 @@
import { hasPermissions } from "#shared/auth.util"; import { hasPermissions } from "#shared/auth.util";
declare module 'nitropack'
{
interface TaskPayload
{
type: string
}
}
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const session = await getUserSession(e); const session = await getUserSession(e);
@ -17,7 +9,6 @@ export default defineEventHandler(async (e) => {
return; return;
} }
const id = getRouterParam(e, 'id'); const id = getRouterParam(e, 'id');
const payload: Record<string, any> = await readBody(e);
if(!id) if(!id)
{ {
@ -25,23 +16,12 @@ export default defineEventHandler(async (e) => {
return; return;
} }
payload.type = id; const result = await runTask(id);
payload.data = JSON.parse(payload.data);
const result = await runTask(id, {
payload: payload
});
if(!result.result) if(!result.result)
{ {
setResponseStatus(e, 500); setResponseStatus(e, 500);
throw result.error ?? new Error('Erreur inconnue');
if(result.error && (result.error as Error).message)
throw result.error;
else if(result.error)
throw new Error(result.error);
else
throw new Error('Erreur inconnue');
} }
return; return
}); });

View File

@ -73,19 +73,6 @@ export default defineEventHandler(async (e): Promise<Return> => {
logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [] } }) as UserSessionRequired); logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [] } }) as UserSessionRequired);
runTask('mail', {
payload: {
type: 'mail',
to: [body.data.email],
template: 'registration',
data: {
username: body.data.username,
timestamp: Date.now(),
id: id.id,
}
}
});
setResponseStatus(e, 201); setResponseStatus(e, 201);
return { success: true, session }; return { success: true, session };
} }

View File

@ -15,11 +15,7 @@ export default defineEventHandler(async (e) => {
navigable: explorerContentTable.navigable, navigable: explorerContentTable.navigable,
private: explorerContentTable.private, private: explorerContentTable.private,
order: explorerContentTable.order, order: explorerContentTable.order,
}).from(explorerContentTable).all(); }).from(explorerContentTable).prepare().all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;
});
if(content.length > 0) if(content.length > 0)
{ {

View File

@ -26,10 +26,6 @@ export default defineEventHandler(async (e) => {
order: explorerContentTable.order, order: explorerContentTable.order,
}).from(explorerContentTable).prepare().all(); }).from(explorerContentTable).prepare().all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;
});
if(content.length > 0) if(content.length > 0)
{ {
const project: Project = { const project: Project = {

View File

@ -3,7 +3,6 @@ import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema'; import { explorerContentTable } from '~/db/schema';
import { project, type ProjectItem } from '~/schemas/project'; import { project, type ProjectItem } from '~/schemas/project';
import { parsePath } from "#shared/general.utils"; import { parsePath } from "#shared/general.utils";
import { eq, getTableColumns } from "drizzle-orm";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e); const { user } = await getUserSession(e);
@ -23,15 +22,6 @@ export default defineEventHandler(async (e) => {
const items = buildOrder(body.data.items); const items = buildOrder(body.data.items);
const db = useDatabase(); const db = useDatabase();
const { content, ...cols } = getTableColumns(explorerContentTable);
const full = db.select(cols).from(explorerContentTable).all();
for(let i = full.length - 1; i >= 0; i--)
{
if(items.find(e => (e.path === '' ? [e.parent, parsePath(e.name === '' ? e.title : e.name)].filter(e => !!e).join('/') : e.path) === full[i].path))
full.splice(i, 1);
}
db.transaction((tx) => { db.transaction((tx) => {
for(let i = 0; i < items.length; i++) for(let i = 0; i < items.length; i++)
{ {
@ -45,10 +35,10 @@ export default defineEventHandler(async (e) => {
navigable: item.navigable, navigable: item.navigable,
private: item.private, private: item.private,
order: item.order, order: item.order,
content: null, content: Buffer.from('', 'utf-8'),
}).onConflictDoUpdate({ }).onConflictDoUpdate({
set: { set: {
path: [item.parent, parsePath(item.name === '' ? item.title : item.name)].filter(e => !!e).join('/'), path: [item.parent, parsePath(item?.name ?? item.title)].filter(e => !!e).join('/'),
title: item.title, title: item.title,
type: item.type, type: item.type,
navigable: item.navigable, navigable: item.navigable,
@ -58,10 +48,6 @@ export default defineEventHandler(async (e) => {
target: explorerContentTable.path, target: explorerContentTable.path,
}).run(); }).run();
} }
for(let i = 0; i < full.length; i++)
{
tx.delete(explorerContentTable).where(eq(explorerContentTable.path, full[i].path)).run();
}
}); });
}); });

View File

@ -1,64 +0,0 @@
import { eq } from "drizzle-orm";
import useDatabase from "~/composables/useDatabase";
import { usersTable } from "~/db/schema";
export default defineEventHandler(async (e) => {
const session = await getUserSession(e);
if(!session || !session.user || !session.user.id)
{
return createError({
statusCode: 401,
message: 'Unauthorized',
});
}
const id = getRouterParam(e, 'id');
if(!id)
{
return createError({
statusCode: 403,
message: 'Forbidden',
});
}
if(session.user.id.toString() !== id)
{
return createError({
statusCode: 401,
message: 'Unauthorized',
});
}
const db = useDatabase();
const data = db.select({ state: usersTable.state }).from(usersTable).where(eq(usersTable.id, session.user.id)).get();
if(!data)
{
return createError({
statusCode: 401,
message: 'Unauthorized',
});
}
if(data.state === 1)
{
setResponseStatus(e, 200);
return;
}
runTask('mail', {
payload: {
type: 'mail',
to: [session.user.email],
template: 'registration', //@TODO
data: {
username: session.user.username,
timestamp: Date.now(),
id: session.user.id,
}
}
});
setResponseStatus(e, 200);
return;
})

View File

@ -1,16 +0,0 @@
<template>
<div style='margin-left: auto; margin-right: auto; width: 75%; font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; line-height: 1.5rem; color: #171717;'>
<div style="margin-left: auto; margin-right: auto; text-align: center;">
<a href="https://obsidian.peaceultime.com">
<img src="https://obsidian.peaceultime.com/logo.light.png" alt="Logo" title="d[any] logo" width="64" height="64" style="display: block; height: 4rem; width: 4rem; margin-left: auto; margin-right: auto;" />
<span style="margin-inline-end: 1rem; font-size: 1.5rem; line-height: 2rem; font-weight: 700; font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;">d[any]</span>
</a>
</div>
<div style="padding: 1rem;">
<slot></slot>
</div>
</div>
<div style="background-color: #171717;">
<p style="padding-top: 1rem; padding-bottom: 1rem; text-align: center; font-size: 0.75rem; line-height: 1rem; color: #fff;">Copyright Peaceultime - d[any] - 2024</p>
</div>
</template>

View File

@ -1,26 +0,0 @@
<template>
<div style="max-width: 800px; margin-left: auto; margin-right: auto;">
<p style="font-variant: small-caps; margin-bottom: 1rem; font-size: 1.25rem; line-height: 1.75rem;">Bienvenue sur d[any], <span>{{ username }}</span>.</p>
<p>Nous vous invitons à valider votre compte afin de profiter de toutes les fonctionnalités de d[any].</p>
<div style="padding-top: 1rem; padding-bottom: 1rem; text-align: center;">
<a :href="`https://obsidian.peaceultime.com/user/mailvalidation?u=${id}&t=${timestamp}&h=${hash}`" target="_blank"><span style="display: inline-block; border-width: 1px; border-color: #525252; background-color: #e5e5e5; padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem; font-weight: 200; color: #171717; text-decoration: none;">Je valide mon compte</span></a>
<span style="display: block; padding-top: 0.5rem; font-size: 0.75rem; line-height: 1rem;">Ce lien est valable 1 heure.</span>
</div>
<div>
<span>Vous pouvez egalement copier le lien suivant pour valider votre compte: </span>
<pre style="display: inline-block; border-bottom-width: 1px; font-size: 0.75rem; line-height: 1rem; color: #171717; font-weight: 400; text-decoration: none;">{{ `https://obsidian.peaceultime.com/user/mailvalidation?u=${id}&t=${timestamp}&h=${hash}` }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import Bun from 'bun';
const { id, username, timestamp } = defineProps<{
id: number
username: string
timestamp: number
}>();
const hash = computed(() => Bun.hash(id.toString(), timestamp));
</script>

View File

@ -1,20 +0,0 @@
<template>
<div class="">
<img />
<p>Bienvenue sur d[any], <span>{{ username }}</span>.</p>
</div>
<p class="">Nous vous invitons à valider votre compte en cliquant <a :href="`https://obsidian.peaceultime.com/user/mail-validation?u=${id}&t=${timestamp}&h=${hash(id.toString(), timestamp)}`"><Button>ici</Button></a> afin de profiter de toutes les fonctionnalités de d[any]</p>
<p class="">Vous pouvez egalement copier le lien suivant pour valider votre compte: {{ `https://obsidian.peaceultime.com/user/mail-validation?u=${id}&t=${timestamp}&h=${hash(id.toString(), timestamp)}` }}</p>
<span>Ce lien est valable 1 heure.</span>
<footer></footer>
</template>
<script setup lang="ts">
import { hash } from 'bun';
const { id, username, timestamp } = defineProps<{
id: number
username: string
timestamp: number
}>();
</script>

View File

@ -1,54 +0,0 @@
import { eq } from "drizzle-orm";
import { z } from "zod";
import useDatabase from "~/composables/useDatabase";
import { usersTable } from "~/db/schema";
const schema = z.object({
h: z.coerce.string(),
u: z.coerce.number(),
t: z.coerce.number(),
});
export default defineEventHandler(async (e) => {
const query = await getValidatedQuery(e, schema.safeParse);
if(!query.success)
throw query.error;
if(Bun.hash(query.data.u.toString(), query.data.t).toString() !== query.data.h)
{
return createError({
statusCode: 400,
message: 'Lien incorrect',
})
}
if(Date.now() > query.data.t + (60 * 60 * 1000))
{
return createError({
statusCode: 400,
message: 'Le lien a expiré',
})
}
const db = useDatabase();
const result = db.select({ state: usersTable.state }).from(usersTable).where(eq(usersTable.id, query.data.u)).get();
if(result === undefined)
{
return createError({
statusCode: 400,
message: 'Aucune donnée utilisateur trouvée',
})
}
if(result?.state === 1)
{
return createError({
statusCode: 400,
message: 'Votre compte a déjà été validé',
})
}
db.update(usersTable).set({ state: 1 }).where(eq(usersTable.id, query.data.u)).run();
sendRedirect(e, '/user/mailvalidated');
})

View File

@ -1,113 +0,0 @@
import nodemailer from 'nodemailer';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import postcss from 'postcss';
import tailwindcss from 'tailwindcss';
import { join } from 'node:path';
import base from '../components/mail/base.vue';
import registration from '../components/mail/registration.vue';
import revalidation from '../components/mail/revalidation.vue';
const config = useRuntimeConfig();
const [domain, selector, dkim] = config.mail.dkim.split(":");
export const templates: Record<string, { component: any, subject: string }> = {
"registration": { component: registration, subject: 'Bienvenue sur d[any] 😎' },
"revalidate-mail": { component: revalidation, subject: 'd[any]: Valider votre email' },
};
import 'nitropack/types';
import type Mail from 'nodemailer/lib/mailer';
declare module 'nitropack/types'
{
interface TaskPayload
{
type: 'mail'
to: string[]
template: string
data: Record<string, any>
}
}
const transport = nodemailer.createTransport({
//@ts-ignore
pool: true,
host: config.mail.host,
port: config.mail.port,
secure: false,
auth: {
user: config.mail.user,
pass: config.mail.passwd,
},
requireTLS: true,
dkim: {
domainName: domain,
keySelector: selector,
privateKey: dkim,
},
});
export default defineTask({
meta: {
name: 'mail',
description: 'Send email',
},
async run(e) {
try {
if(e.payload.type !== 'mail')
{
throw new Error(`Données inconnues`);
}
const payload = e.payload;
const template = templates[payload.template];
if(!template)
{
throw new Error(`Modèle de mail ${payload.template} inconnu`);
}
console.time('Generating HTML');
const mail: Mail.Options = {
from: 'd[any] - Ne pas répondre <no-reply@peaceultime.com>',
to: payload.to,
html: await render(template.component, payload.data),
subject: template.subject,
textEncoding: 'quoted-printable',
};
console.timeEnd('Generating HTML');
if(mail.html === '')
return { result: false, error: new Error("Invalid content") };
console.time('Sending Mail');
const status = await transport.sendMail(mail);
console.timeEnd('Sending Mail');
if(status.rejected.length > 0)
{
return { result: false, error: status.response, details: status.rejectedErrors };
}
return { result: true };
}
catch(e)
{
return { result: false, error: e };
}
},
})
async function render(component: any, data: Record<string, any>): Promise<string>
{
const app = createSSRApp({
render(){
return h(base, null, { default: () => h(component, data, { default: () => null }) });
}
});
const html = await renderToString(app);
return (`<html><body><div>${html}</div></body></html>`);
}

View File

@ -1,8 +1,7 @@
import useDatabase from "~/composables/useDatabase"; import useDatabase from "~/composables/useDatabase";
import { extname, basename } from 'node:path'; import type { FileType } from '~/types/api';
import type { File, FileType, Tag } from '~/types/api';
import type { CanvasColor, CanvasContent } from "~/types/canvas";
import { explorerContentTable } from "~/db/schema"; import { explorerContentTable } from "~/db/schema";
import { eq, ne } from "drizzle-orm";
const typeMapping: Record<string, FileType> = { const typeMapping: Record<string, FileType> = {
".md": "markdown", ".md": "markdown",
@ -27,88 +26,14 @@ export default defineTask({
} }
}) as any; }) as any;
const files: typeof explorerContentTable.$inferInsert = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e: any) => {
if(e.type === 'tree')
{
const title = basename(e.path);
const order = /(\d+)\. ?(.+)/gsmi.exec(title);
const path = (e.path as string).split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/');
return {
path: path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
order: order && order[1] ? order[1] : 0,
title: order && order[2] ? order[2] : title,
type: 'folder',
content: null,
owner: '1',
navigable: true,
private: e.path.startsWith('98.Privé'),
}
}
const extension = extname(e.path);
const title = basename(e.path, extension);
const order = /(\d+)\. ?(.+)/gsmi.exec(title);
const path = (e.path as string).split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/');
const content = (await $fetch(`https://git.peaceultime.com/api/v1/repos/peaceultime/system-aspect/raw/${encodeURIComponent(e.path)}`));
return {
path: (extension === '.md' ? path.replace(extension, '') : path).toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
order: order && order[1] ? order[1] : 0,
title: order && order[2] ? order[2] : title,
type: (typeMapping[extension] ?? 'file'),
content: reshapeContent(content as string, typeMapping[extension] ?? 'File'),
owner: '1',
navigable: true,
private: e.path.startsWith('98.Privé')
}
}));
const db = useDatabase(); const db = useDatabase();
db.delete(explorerContentTable).run(); const files = db.select().from(explorerContentTable).where(ne(explorerContentTable.type, 'folder')).all();
db.insert(explorerContentTable).values(files).run();
useStorage('cache').clear();
return { result: true }; return { result: true };
} }
catch(e) catch(e)
{ {
console.error(e);
return { result: false, error: e }; return { result: false, error: e };
} }
}, },
}) })
function reshapeContent(content: string, type: FileType): string | null
{
switch(type)
{
case "markdown":
case "file":
return content;
case "canvas":
const data = JSON.parse(content) as CanvasContent;
data.edges.forEach(e => e.color = typeof e.color === 'string' ? getColor(e.color) : undefined);
data.nodes.forEach(e => e.color = typeof e.color === 'string' ? getColor(e.color) : undefined);
return JSON.stringify(data);
default:
case 'folder':
return null;
}
}
function getColor(color: string): CanvasColor
{
const colors: Record<string, string> = {
'1': 'red',
'2': 'orange',
'3': 'yellow',
'4': 'green',
'5': 'cyan',
'6': 'purple',
};
if(colors.hasOwnProperty(color))
return { class: colors[color] };
else
return { hex: color };
}

View File

@ -1,7 +1,8 @@
import useDatabase from "~/composables/useDatabase"; import useDatabase from "~/composables/useDatabase";
import type { FileType } from '~/types/api'; import { extname, basename } from 'node:path';
import type { File, FileType, Tag } from '~/types/api';
import type { CanvasColor, CanvasContent } from "~/types/canvas";
import { explorerContentTable } from "~/db/schema"; import { explorerContentTable } from "~/db/schema";
import { eq, ne } from "drizzle-orm";
const typeMapping: Record<string, FileType> = { const typeMapping: Record<string, FileType> = {
".md": "markdown", ".md": "markdown",
@ -26,8 +27,47 @@ export default defineTask({
} }
}) as any; }) as any;
const files: typeof explorerContentTable.$inferInsert = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e: any) => {
if(e.type === 'tree')
{
const title = basename(e.path);
const order = /(\d+)\. ?(.+)/gsmi.exec(title);
const path = (e.path as string).split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/');
return {
path: path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
//order: order && order[1] ? order[1] : 50,
title: order && order[2] ? order[2] : title,
type: 'folder',
content: null,
owner: '1',
navigable: true,
private: e.path.startsWith('98.Privé'),
}
}
const extension = extname(e.path);
const title = basename(e.path, extension);
const order = /(\d+)\. ?(.+)/gsmi.exec(title);
const path = (e.path as string).split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/');
const content = (await $fetch(`https://git.peaceultime.com/api/v1/repos/peaceultime/system-aspect/raw/${encodeURIComponent(e.path)}`));
return {
path: (extension === '.md' ? path.replace(extension, '') : path).toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
//order: order && order[1] ? order[1] : 50,
title: order && order[2] ? order[2] : title,
type: (typeMapping[extension] ?? 'file'),
content: reshapeContent(content as string, typeMapping[extension] ?? 'File'),
owner: '1',
navigable: true,
private: e.path.startsWith('98.Privé')
}
}));
const db = useDatabase(); const db = useDatabase();
const files = db.select().from(explorerContentTable).where(ne(explorerContentTable.type, 'folder')).all(); db.delete(explorerContentTable).run();
db.insert(explorerContentTable).values(files).run();
useStorage('cache').clear();
return { result: true }; return { result: true };
} }
@ -37,3 +77,36 @@ export default defineTask({
} }
}, },
}) })
function reshapeContent(content: string, type: FileType): string | null
{
switch(type)
{
case "markdown":
case "file":
return content;
case "canvas":
const data = JSON.parse(content) as CanvasContent;
data.edges.forEach(e => e.color = typeof e.color === 'string' ? getColor(e.color) : undefined);
data.nodes.forEach(e => e.color = typeof e.color === 'string' ? getColor(e.color) : undefined);
return JSON.stringify(data);
default:
case 'folder':
return null;
}
}
function getColor(color: string): CanvasColor
{
const colors: Record<string, string> = {
'1': 'red',
'2': 'orange',
'3': 'yellow',
'4': 'green',
'5': 'cyan',
'6': 'purple',
};
if(colors.hasOwnProperty(color))
return { class: colors[color] };
else
return { hex: color };
}

View File

@ -1,12 +1,10 @@
import type { FileType } from "~/schemas/file";
export function unifySlug(slug: string | string[]): string export function unifySlug(slug: string | string[]): string
{ {
return (Array.isArray(slug) ? slug.join('/') : slug); return (Array.isArray(slug) ? slug.join('/') : slug);
} }
export function parsePath(path: string): string export function parsePath(path: string): string
{ {
return path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replaceAll(/[\u0300-\u036f]/g, "").replaceAll('(', '').replaceAll(')', ''); return path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
} }
export function parseId(id: string | undefined): string |undefined export function parseId(id: string | undefined): string |undefined
{ {
@ -46,10 +44,3 @@ export function clamp(x: number, min: number, max: number): number {
return min; return min;
return x; return x;
} }
export const iconByType: Record<FileType, string> = {
'folder': 'lucide:folder',
'canvas': 'ph:graph-light',
'file': 'radix-icons:file',
'markdown': 'radix-icons:file',
}