Compress stored content for improved caching size and speed. Add loading component on every Content ready awaiting to reduce first render time.

This commit is contained in:
Clément Pons 2025-10-28 17:57:20 +01:00
parent 1c3211d28e
commit fde752b6ed
6 changed files with 77 additions and 61 deletions

BIN
db.sqlite

Binary file not shown.

View File

@ -1,16 +1,19 @@
<template> <template>
<Head> <Head>
<Title>d[any] - Erreur {{ error?.statusCode }}</Title> <Title>d[any] - Erreur {{ error?.statusCode }}</Title>
</Head> </Head>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden justify-center items-center flex-col gap-4"> <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/> <NuxtRouteAnnouncer/>
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<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="text-center text-wrap">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
<Button @click="handleError">Revenir en lieu sûr</Button> <button class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
</div> text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 p-2" @click="handleError">Revenir en lieu sûr</button>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -56,35 +56,21 @@ import { Content, iconByType } from '#shared/content.util';
import { dom, icon } from '#shared/dom.util'; import { dom, icon } from '#shared/dom.util';
import { unifySlug } from '#shared/general.util'; import { unifySlug } from '#shared/general.util';
import { tooltip } from '#shared/floating.util'; import { tooltip } from '#shared/floating.util';
import { link } from '#shared/components.util'; import { link, loading } from '#shared/components.util';
const open = ref(false); const open = ref(false);
let tree: TreeDOM | undefined;
const { loggedIn, user } = useUserSession(); const { loggedIn, user } = useUserSession();
const route = useRouter().currentRoute; const route = useRouter().currentRoute;
const path = computed(() => route.value.params.path ? decodeURIComponent(unifySlug(route.value.params.path)) : undefined); const path = computed(() => route.value.params.path ? decodeURIComponent(unifySlug(route.value.params.path)) : undefined);
await Content.init();
const tree = new TreeDOM((item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private } }, [
icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 1.5 - 1}em` } }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined,
])]);
}, (item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [link([
icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined,
], { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, active: 'text-accent-blue' }, item.path ? { name: 'explore-path', params: { path: item.path } } : undefined )]);
}, (item) => item.navigable);
(path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(tree.tree.search('path', e)[0], true));
const unmount = useRouter().afterEach((to, from, failure) => { const unmount = useRouter().afterEach((to, from, failure) => {
if(failure) if(failure)
return; return;
to.name === 'explore-path' && (unifySlug(to.params.path ?? '').split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(tree.tree.search('path', e)[0], true)); to.name === 'explore-path' && (unifySlug(to.params.path ?? '').split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree?.toggle(tree.tree.search('path', e)[0], true));
}); });
watch(route, () => { watch(route, () => {
@ -94,7 +80,27 @@ watch(route, () => {
const treeParent = useTemplateRef('treeParent'); const treeParent = useTemplateRef('treeParent');
onMounted(() => { onMounted(() => {
if(treeParent.value) if(treeParent.value)
treeParent.value.appendChild(tree.container); {
treeParent.value.replaceChildren(loading('normal'));
Content.ready.then(() => {
tree = new TreeDOM((item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private } }, [
icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 1.5 - 1}em` } }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined,
])]);
}, (item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [link([
icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined,
], { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, active: 'text-accent-blue' }, item.path ? { name: 'explore-path', params: { path: item.path } } : undefined )]);
}, (item) => item.navigable);
(path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree?.toggle(tree.tree.search('path', e)[0], true));
treeParent.value!.replaceChildren(tree.container);
})
}
}) })
onUnmounted(() => { onUnmounted(() => {
unmount(); unmount();

View File

@ -15,9 +15,9 @@ const route = useRouter().currentRoute;
const path = computed(() => unifySlug(route.value.params.path ?? '')); const path = computed(() => unifySlug(route.value.params.path ?? ''));
onMounted(async () => { onMounted(async () => {
if(element.value && path.value && await Content.ready) if(element.value && path.value)
{ {
overview.value = Content.render(element.value, path.value); overview.value = await Content.render(element.value, path.value);
} }
}); });
</script> </script>

View File

@ -86,7 +86,7 @@ function push()
} }
onMounted(async () => { onMounted(async () => {
if(tree.value && container.value && await Content.ready) if(tree.value && container.value)
{ {
const load = loading('normal'); const load = loading('normal');
tree.value.appendChild(load); tree.value.appendChild(load);
@ -100,7 +100,7 @@ onMounted(async () => {
editor = new Editor(); editor = new Editor();
tree.value.replaceChild(editor.tree.container, load); Content.ready.then(() => tree.value!.replaceChild(editor.tree.container, load));
container.value.appendChild(editor.container); container.value.appendChild(editor.container);
} }
}); });

View File

@ -3,7 +3,7 @@ import { Canvas, CanvasEditor } from "#shared/canvas.util";
import render from "#shared/markdown.util"; import render from "#shared/markdown.util";
import { confirm, contextmenu, tooltip } from "#shared/floating.util"; import { confirm, contextmenu, tooltip } from "#shared/floating.util";
import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util"; import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util";
import { loading } from "#shared/components.util"; import { async, loading } from "#shared/components.util";
import prose, { h1, h2 } from "#shared/proses"; import prose, { h1, h2 } from "#shared/proses";
import { getID, parsePath } from '#shared/general.util'; import { getID, parsePath } from '#shared/general.util';
import { TreeDOM, type Recursive } from '#shared/tree'; import { TreeDOM, type Recursive } from '#shared/tree';
@ -302,8 +302,9 @@ export class Content
const handle = await Content.root.getFileHandle(path, options); const handle = await Content.root.getFileHandle(path, options);
const file = await handle.getFile(); const file = await handle.getFile();
const text = await file.text(); //@ts-ignore
return text; const response = await new Response(file.stream().pipeThrough(new DecompressionStream('gzip')));
return await response.text();
} }
catch(e) catch(e)
{ {
@ -330,15 +331,18 @@ export class Content
//Easy to use, but not very performant. //Easy to use, but not very performant.
private static async write(path: string, content: string, options?: FileSystemGetFileOptions): Promise<void> private static async write(path: string, content: string, options?: FileSystemGetFileOptions): Promise<void>
{ {
const size = new TextEncoder().encode(content).byteLength;
try try
{ {
const parent = path.split('/').slice(0, -1).join('/'), basename = path.split('/').slice(-1).join('/'); const parent = path.split('/').slice(0, -1).join('/'), basename = path.split('/').slice(-1).join('/');
const handle = await (await Content.goto(parent, { create: true }) ?? Content.root).getFileHandle(basename, options); const handle = await (await Content.goto(parent, { create: true }) ?? Content.root).getFileHandle(basename, options);
const file = await handle.createWritable({ keepExistingData: false }); const file = await handle.createWritable({ keepExistingData: false });
await file.write(content); await new ReadableStream({
await file.close(); start(controller) {
controller.enqueue(new TextEncoder().encode(content));
controller.close();
}
}).pipeThrough(new CompressionStream("gzip")).pipeTo(file);
} }
catch(e) catch(e)
{ {
@ -373,26 +377,27 @@ export class Content
return handlers[overview.type].fromString(content); return handlers[overview.type].fromString(content);
} }
static render(parent: HTMLElement, path: string): Omit<LocalContent, 'content'> | undefined static async render(parent: HTMLElement, path: string): Promise<Omit<LocalContent, 'content'> | undefined>
{ {
parent.appendChild(dom('div', { class: 'flex, flex-1 justify-center items-center' }, [loading('normal')]))
await Content.ready;
const overview = Content.getFromPath(path); const overview = Content.getFromPath(path);
if(!!overview) if(!!overview)
{ {
const load = dom('div', { class: 'flex, flex-1 justify-center items-center' }, [loading('normal')]);
parent.appendChild(load);
function _render<T extends FileType>(content: LocalContent<T>): void function _render<T extends FileType>(content: LocalContent<T>): void
{ {
const el = handlers[content.type].render(content); const el = handlers[content.type].render(content);
el && parent.replaceChild(el, load); el && parent.replaceChildren(el);
} }
Content.getContent(overview.id).then(content => _render(content!)); Content.getContent(overview.id).then(content => _render(content!));
} }
else else
{ {
parent.appendChild(dom('h2', { class: 'flex-1 text-center', text: "Impossible d'afficher le contenu demandé" })); parent.replaceChildren(dom('h2', { class: 'flex-1 text-center', text: "Impossible d'afficher le contenu demandé" }));
} }
return overview; return overview;
@ -514,7 +519,7 @@ const handlers: { [K in FileType]: ContentTypeHandler<K> } = {
//TODO: Edition link //TODO: Edition link
]), ]),
]), ]),
render(content.content), render(content.content, undefined, { class: 'pb-64' }),
]) ])
}, },
renderEditor: (content) => { renderEditor: (content) => {
@ -572,7 +577,7 @@ export const iconByType: Record<FileType, string> = {
export class Editor export class Editor
{ {
tree: TreeDOM; tree!: TreeDOM;
container: HTMLDivElement; container: HTMLDivElement;
selected?: Recursive<LocalContent & { element?: HTMLElement }>; selected?: Recursive<LocalContent & { element?: HTMLElement }>;
@ -692,29 +697,31 @@ export class Editor
}, },
}, () => { this.tree.tree.each(e => Content.set(e.id, e)); Content.save(); }); }, () => { this.tree.tree.each(e => Content.set(e.id, e)); Content.save(); });
this.tree = new TreeDOM((item, depth) => { Content.ready.then(() => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent)} }, [ this.tree = new TreeDOM((item, depth) => {
icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 2 - 1.5}em` } }), return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent)} }, [
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }), icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 2 - 1.5}em` } }),
tooltip(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), 'Navigable', 'left'), dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
tooltip(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), 'Privé', 'right'), tooltip(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), 'Navigable', 'left'),
])]); tooltip(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), 'Privé', 'right'),
}, (item, depth) => { ])]);
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent), click: () => this.select(item as LocalContent) } }, [ }, (item, depth) => {
icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }), return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent), click: () => this.select(item as LocalContent) } }, [
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }), icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }),
tooltip(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), 'Navigable', 'left'), dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
tooltip(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), 'Privé', 'right'), tooltip(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), 'Navigable', 'left'),
])]); tooltip(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), 'Privé', 'right'),
])]);
});
this.select(this.tree.tree.find(useRouter().currentRoute.value.hash.substring(1)) as Recursive<LocalContent & { element?: HTMLElement }> | undefined);
this.cleanup = this.setupDnD();
}); });
this.instruction = dom('div', { class: 'absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50' }); this.instruction = dom('div', { class: 'absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50' });
this.cleanup = this.setupDnD();
this.container = dom('div', { class: 'flex flex-1 flex-col items-start justify-start max-h-full relative' }, [dom('div', { class: 'py-4 flex-1 w-full max-h-full flex overflow-auto xl:px-12 lg:px-8 px-6 relative' })]); this.container = dom('div', { class: 'flex flex-1 flex-col items-start justify-start max-h-full relative' }, [dom('div', { class: 'py-4 flex-1 w-full max-h-full flex overflow-auto xl:px-12 lg:px-8 px-6 relative' })]);
this.select(this.tree.tree.find(useRouter().currentRoute.value.hash.substring(1)) as Recursive<LocalContent & { element?: HTMLElement }> | undefined);
} }
private contextmenu(e: MouseEvent, item: Recursive<LocalContent>) private contextmenu(e: MouseEvent, item: Recursive<LocalContent>)
{ {