Layouts, ProseA rework and PreviewContent creation. Trying to fix hydration by making better SSR.

This commit is contained in:
Peaceultime 2024-08-20 18:02:16 +02:00
parent 2e92c389a2
commit 04785ecf27
20 changed files with 344 additions and 359 deletions

73
app.vue
View File

@ -1,73 +1,8 @@
<script lang="ts">
let icon: HTMLDivElement | null;
function toggleLeftPanel(_: Event): void {
document.querySelector('.published-container')?.classList.toggle('is-left-column-open');
}
function hideLeftPanel(_: Event): void {
document.querySelector('.published-container')?.classList.remove('is-left-column-open');
}
</script>
<script setup lang="ts">
const { id: project, home, get } = useProject();
if(project.value !== 0 && home.value === null)
{
await useAsyncData(`project:get:${project}`, get);
}
const toggled = ref(false);
onMounted(() => {
icon = document.querySelector('.site-nav-bar .clickable-icon');
icon?.removeEventListener('click', toggleLeftPanel);
icon?.addEventListener('click', toggleLeftPanel);
});
</script>
<template> <template>
<div class="published-container print has-navigation has-outline"> <div class="published-container">
<div class="site-nav-bar"> <NuxtLayout>
<div> <NuxtPage></NuxtPage>
<div class="clickable-icon"> </NuxtLayout>
<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-menu">
<line x1="4" y1="12" x2="20" y2="12"></line>
<line x1="4" y1="6" x2="20" y2="6"></line>
<line x1="4" y1="18" x2="20" y2="18"></line>
</svg>
</div>
<div class="gapx-3 flex align-stretch">
<NuxtLink @click="hideLeftPanel" class="site-nav-bar-text" aria-label="Accueil" :to="{ path: '/', force: true }">
<ThemeIcon icon="logo" :width=40 :height=40 />
</NuxtLink>
<div class="site-nav-bar-text mobile-hidden">
<NuxtLink aria-label="Projet" :to="{ path: project === 0 ? `/explorer` : `/explorer/${project}${home}`, force: true }"
:class="{'mod-active': $route.path.startsWith('/explorer')}">Projet</NuxtLink>
<div class="arrow-down" :class="{active: toggled}" @click="toggled = !toggled"></div>
<div class="arrow-group" @click="toggled = false">
<NuxtLink class="arrow-group-item" aria-label="Projets" :to="{ path: '/explorer', force: true }">Liste des projets</NuxtLink>
</div>
</div>
<NuxtLink class="site-nav-bar-text mobile-hidden" aria-label="Editeur" :to="{ path: '/editing', force: true }"
:class="{'mod-active': $route.path.startsWith('/editing')}">Editeur</NuxtLink>
</div>
</div>
<div class="mobile-bigger">
<SearchView />
</div>
<div class="ps-1 gapx-1 flex align-center">
<ThemeSwitch class="mobile-hidden" />
<NuxtLink class="site-login" :to="{ path: '/user/profile', force: true }">
<ThemeIcon icon="user" :width=32 :height=32 />
</NuxtLink>
</div>
</div>
<div class="site-body">
<LeftComponent />
<NuxtPage />
</div>
<div class="site-footer"> <div class="site-footer">
<p>Copyright Peaceultime - 2024</p> <p>Copyright Peaceultime - 2024</p>
<NuxtLink :to="{ path: '/third-party', force: true }">Applications tierces et crédits</NuxtLink> <NuxtLink :to="{ path: '/third-party', force: true }">Applications tierces et crédits</NuxtLink>

View File

@ -2,6 +2,6 @@
- [Vuejs](https://vuejs.org/) - MIT License - Copyright (c) 2018-present, Yuxi (Evan) You and Vue contributors - [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 - [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](https://nuxt.com/) - MIT License - Copyright (c) 2016-present - Nuxt Team
- [NuxtAuth](https://auth.sidebase.io/) - MIT License - Copyright (c) 2022 SIDESTREAM GmbH - [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). Le logo a été créé grace aux icones de [Game Icons](https://game-icons.net).

View File

@ -43,7 +43,7 @@ import LiveBlockquote from "~/components/prose/ProseBlockquote.vue";
import { hash } from 'ohash' import { hash } from 'ohash'
import { watch, computed } from 'vue' import { watch, computed } from 'vue'
import type { Root } from 'hast'; import type { Root, Node } from 'hast';
import { diffLines as diff } from 'diff'; import { diffLines as diff } from 'diff';
const model = defineModel<string>(); const model = defineModel<string>();
@ -51,55 +51,81 @@ const model = defineModel<string>();
const parser = useMarkdown(); const parser = useMarkdown();
const key = computed(() => hash(model.value)); const key = computed(() => hash(model.value));
const node = ref<Root>(); const node = ref<Root>(), changes = ref();
watch(model, async (value, old) => { watch(model, update);
update(model.value, "");
async function update(value: string | undefined, old: string | undefined) {
if(value && old) if(value && old)
{ {
if(node.value) if(node.value)
{ {
let content = "", line = 0, pos = -1, len = 0, child; const differences = diff(old, value, {
const d = diff(old, value); newlineIsToken: true,
const children = node.value?.children.filter(e => e.hasOwnProperty('position')); });
for(let i = 0; i < d.length; i++) 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(d[i].added) //Nouvelle ligne if(!difference.added && !difference.removed)
{ {
const next = d.length > i ? d[i + 1] : undefined; removeStart += difference.value.length;
if(pos === -1 && (!next || !next.removed)) //Nouvelle ligne addStart += difference.value.length;
{
child = children.filter(e => e.position?.start.line <= line && e.position?.end.line >= line); //Je cherche tout les blocs qui était inclus dans les lignes éditées.
if(child.length > 0)
{
pos = child[0].position?.start.offset ?? 0; //Je pars du premier caractère du bloc
len += (child[child.length - 1].position?.end.offset ?? 0) + 1; //Je m'arrete au dernier caractère du bloc + le \n
}
}
len += d[i].value.length; // J'ajoute le nouveau nombre de caractère
} }
else if(d[i].removed) //Ancienne ligne else if(difference.added)
{ {
child = children.filter(e => e.position?.start.line <= line + 1 && e.position?.end.line >= line + (d[i].count ?? 1)); //Je cherche tout les blocs qui était inclus dans les lignes éditées. addEnd = addStart + difference.value.length;
if(child.length > 0)
{
pos = child[0].position?.start.offset ?? 0; //Je pars du premier caractère du bloc
len += child[child.length - 1].position?.end.offset ?? 0 + 1; //Je m'arrete au dernier caractère du bloc
len -= d[i].value.length; //Je supprime l'ancien nombre de caractère
}
} }
else else if(difference.removed)
{ {
line += d[i].count ?? 0; removeEnd = removeStart + difference.value.length;
} }
if((!needAdd || addEnd !== 0) && (!needRemove || removeEnd !== 0))
break;
} }
node.value = parser(value); 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;
//root.position?.end.offset
node.value = parser(value);
}
console.log(node.value);
} }
else else
{ {
node.value = parser(value); node.value = parser(value);
} }
} }
}, { immediate: true }); 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> </script>

View File

@ -1,7 +1,10 @@
<template> <template>
<template <template
v-if="model && model.length > 0"> v-if="model && model.length > 0">
<MarkdownRenderer :key="key" v-if="node" :node="node"></MarkdownRenderer> <Suspense>
<MarkdownRenderer #default :key="key" v-if="node" :node="node"></MarkdownRenderer>
<template #fallback><div class="loading"></div></template>
</Suspense>
</template> </template>
</template> </template>

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Node, Text, Element, Comment, Root } from 'hast'; import type { RootContent, Root } from 'hast';
import { Text as HText, Comment as HComment } from 'vue'; import { Text, Comment } from 'vue';
import ProseP from '~/components/prose/ProseP.vue'; import ProseP from '~/components/prose/ProseP.vue';
import ProseA from '~/components/prose/ProseA.vue'; import ProseA from '~/components/prose/ProseA.vue';
@ -90,30 +90,30 @@ export default defineComponent({
render(ctx: any) { render(ctx: any) {
const { node, tags } = ctx; const { node, tags } = ctx;
const div = h('div', null, (node as Root).children.map(e => renderNode(e, tags)).filter(e => !!e)); if(!node)
return div; return null;
return h('div', null, {default: () => (node as Root).children.map(e => renderNode(e, tags)).filter(e => !!e)});
} }
}); });
function renderNode(node: Node, tags: Record<string, any>): VNode | undefined function renderNode(node: RootContent, tags: Record<string, any>): VNode | undefined
{ {
if(node.type === 'text') if(node.type === 'text' && node.value.length > 0 && node.value !== '\n')
{ {
const text = node as Text; return h(Text, node.value);
if(text.value.length > 0 && text.value !== '\n')
return h(HText, (node as Text).value);
} }
else if(node.type === 'comment') else if(node.type === 'comment' && node.value.length > 0 && node.value !== '\n')
{ {
const comment = node as Comment; return h(Comment, node.value);
if(comment.value.length > 0 && comment.value !== '\n')
return h(HComment, (node as Comment).value);
} }
else if(node.type === 'element') else if(node.type === 'element')
{ {
const element = node as Element; return h(tags[node.tagName] ?? node.tagName, { ...node.properties, class: node.properties.className }, {default: () => node.children.map(e => renderNode(e, tags)).filter(e => !!e)});
}
return h(tags[element.tagName], { ...element.properties, class: element.properties.className }, element.children.map(e => renderNode(e, tags)).filter(e => !!e)); else if(node.type === 'raw')
{
console.warn("???");
} }
return undefined; return undefined;

View File

@ -44,7 +44,7 @@ async function debounced()
<input class="search-bar" type="text" placeholder="Recherche" v-model="input" @input="search"> <input class="search-bar" type="text" placeholder="Recherche" v-model="input" @input="search">
</div> </div>
</div> </div>
<Teleport to="body" v-if="input !== '' && !!pos"> <Teleport to="#teleports" v-if="input !== '' && !!pos">
<div class="search-results" <div class="search-results"
:style="{top: (pos.bottom + 4) + 'px', left: pos.left + 'px', width: pos.width + 'px'}"> :style="{top: (pos.bottom + 4) + 'px', left: pos.left + 'px', width: pos.width + 'px'}">
<div class="loading" v-if="loading"></div> <div class="loading" v-if="loading"></div>

View File

@ -1,33 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
function hideLeftPanel(_: Event)
{
document?.querySelector('.published-container')?.classList.remove('is-left-column-open');
}
const route = useRoute(); const route = useRoute();
const showing = ref(false);
const project = computed(() => parseInt(Array.isArray(route.params.projectId) ? '0' : route.params.projectId)); const project = computed(() => parseInt(Array.isArray(route.params.projectId) ? '0' : route.params.projectId));
const { data: navigation, execute, status, error } = await useFetch(() => `/api/project/${project.value}/navigation`, { const { data: navigation, execute, status, error } = await useFetch(() => `/api/project/${project.value}/navigation`, {
immediate: false, immediate: false,
}); });
watch(route, () => {
if(route.params.projectId && project.value !== 0)
{
showing.value = true;
execute();
}
else
{
showing.value = false;
}
})
</script> </script>
<template> <template>
<div :class="{'desktop-hidden': !showing}" class="site-body-left-column"> <div class="site-body-left-column">
<div class="site-body-left-column-inner"> <div class="site-body-left-column-inner">
<div class="nav-view-outer"> <div class="nav-view-outer">
<div class="nav-view"> <div class="nav-view">

View File

@ -0,0 +1,76 @@
<template>
<Teleport to="#teleports">
<div class="popover hover-popover" :class="{'is-loaded': pending === false && content !== ''}" :style="{'top': (pos?.y ?? 0) - 4, 'left': (pos?.y ?? 0) + 4}">
<template v-if="display">
<div v-if="pending" class="loading"></div>
<div class="markdown-embed" v-else-if="content !== ''">
<div class="markdown-preview-view markdown-rendered">
<div class="markdown-embed-content">
<h1>{{ title }}</h1>
<Markdown v-model="content"></Markdown>
</div>
</div>
</div>
<div class="not-found-container" v-else>
<div class="not-found-image"></div>
<div class="not-found-title">Impossible d'afficher (ou vide)</div>
<div class="not-found-description">Cette page est actuellement vide ou impossible à traiter</div>
</div>
</template>
</div>
</Teleport>
<span ref="el" @mouseenter="show" @mouseleave="hide">
<slot></slot>
</span>
</template>
<script setup lang="ts">
import { useDebounceFn as debounce } from '@vueuse/core';
const props = defineProps({
project: {
type: Number,
required: true,
},
path: {
type: String,
required: true,
},
anchor: {
type: String,
required: false,
}
})
const content = ref(''), title = ref(''), display = ref(false), pending = ref(false);
const el = ref(), pos = ref<DOMRect>();
watch(display, () => display.value && !pending.value && content.value === '' && fetch());
onMounted(() => {
if(el && el.value)
{
pos.value = (el.value as HTMLDivElement).getBoundingClientRect();
debugger;
}
})
async function fetch()
{
pending.value = true;
const data = await $fetch(`/api/project/${props.project}/file`, {
method: 'get',
query: {
path: props.path,
},
});
pending.value = false;
if(data && data[0])
{
content.value = data[0].content;
title.value = data[0].title;
}
}
const show = debounce(() => display.value = true, 250), hide = debounce(() => display.value = false, 250);
</script>

View File

@ -1,28 +0,0 @@
<script setup lang="ts">
interface TocItem {
text: string
id: string
children?: TocItem[]
[key: string]: any
}
interface Props {
link: TocItem;
}
const props = defineProps<Props>();
const hasChildren = computed(() => {
return props.link && props.link.children && props.link.children.length > 0 || false;
});
</script>
<template>
<div class="tree-item">
<div class="tree-item-self" :class="{'is-clickable': hasChildren}" data-path="{{ props.link.title }}">
<NuxtLink no-prefetch class="tree-item-inner" :to="{ hash: '#' + props.link.id, force: true }">{{ props.link.text }}</NuxtLink>
</div>
<div class="tree-item-children">
<TocLink v-if="hasChildren" v-for="link of props.link.children" :link="link" />
</div>
</div>
</template>

View File

@ -1,103 +1,39 @@
<template> <template>
<Teleport to="body" v-if="!external && hovered && status !== 'pending'"> <NuxtLink v-if="data && data[0] && status !== 'pending'" :to="{ path: `/explorer/${project}${data[0].path}`, hash: hash }" :class="class">
<div class="popover hover-popover is-loaded" :style="pos" @mouseenter="(e) => showPreview(e, false)" <PreviewContent :project="project" :path="data[0].path" :anchor="hash">
@mouseleave="hidePreview"> <slot v-bind="$attrs"></slot>
<template v-if="!!page && !!page[0]"> </PreviewContent>
<div class="markdown-embed" v-if="page[0].type === 'Markdown' && page[0].content.length > 0"> </NuxtLink>
<div class="markdown-embed-content"> <NuxtLink v-else-if="href" :to="{ path: href }" :class="class">
<div class="markdown-preview-view markdown-rendered node-insert-event hide-title">
<div class="markdown-preview-sizer markdown-preview-section" style="padding-bottom: 0px;">
<h1 v-if="page[0]?.title">{{ page[0]?.title }}</h1>
<Markdown v-model="page[0].content" />
</div>
</div>
</div>
</div>
<div v-else-if="page[0].type === 'Canvas'" class="canvas-embed is-loaded">
<CanvasRenderer :canvas="JSON.parse(page[0].content)" />
</div>
<div class="markdown-embed" v-else>
<div class="not-found-container">
<div class="not-found-title">Impossible de prévisualiser</div>
<div class="not-found-description">Cliquez sur le lien pour accéder au contenu</div>
</div>
</div>
</template>
<div v-else class="loading"></div>
</div>
</Teleport>
<NuxtLink :to="{ path, force: true }" :class="class" noPrefetch @mouseenter="(e) => showPreview(e, true)" @mouseleave="hidePreview">
<slot v-bind="$attrs"></slot> <slot v-bind="$attrs"></slot>
</NuxtLink> </NuxtLink>
<slot :class="class" v-else v-bind="$attrs"></slot>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { parseURL } from 'ufo'; import { parseURL } from 'ufo';
function showPreview(e: Event, bbox: boolean) { const props = defineProps({
clearTimeout(timeout); href: {
if (bbox) { type: String,
const target = e.currentTarget as HTMLElement; required: false,
const rect = target?.getBoundingClientRect();
const r: any = {};
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;
}
hovered.value = true;
}
function hidePreview(e: Event) {
timeout = setTimeout(() => hovered.value = false, 300);
}
interface Prop
{
href?: string;
class?: string;
project?: number;
}
const props = defineProps<Prop>();
const path = ref(props.href), external = ref(false), hovered = ref(false), pos = ref<any>();
let timeout: NodeJS.Timeout;
if (parseURL(props.href).protocol !== undefined)
{
external.value = true;
}
let id = ref(props.project);
if(id.value === undefined)
{
id.value = useProject().id.value;
}
const { data: page, status, error, execute } = await useLazyFetch(`/api/project/${id.value}/file`, {
query: {
search: "%" + parseURL(props.href).pathname,
}, },
immediate: false, class: {
dedupe: 'defer', type: String,
required: false,
}
}); });
if(!external.value) const route = useRoute();
{ const { hash, pathname } = parseURL(props.href);
await execute(); const project = computed(() => parseInt(Array.isArray(route.params.projectId) ? '0' : route.params.projectId));
} const { data, status } = await useFetch(`/api/project/${project.value}/file`, {
method: 'GET',
if(status.value === 'error') query: {
{ search: `%${pathname}`
console.error(error.value); },
} transform: (data) => data?.map(e => ({ path: e.path })),
key: `file:${project.value}:%${pathname}`,
if (page.value && page.value[0]) dedupe: 'defer'
{ });
path.value = `/explorer/${id.value}${page.value[0].path}`;
console.log(path.value);
}
</script> </script>

View File

@ -4,6 +4,7 @@ import RemarkParse from "remark-parse";
import RemarkRehype from 'remark-rehype'; import RemarkRehype from 'remark-rehype';
import RemarkOfm from 'remark-ofm'; import RemarkOfm from 'remark-ofm';
import RemarkBreaks from 'remark-breaks'
import RemarkGfm from 'remark-gfm'; import RemarkGfm from 'remark-gfm';
export default function useMarkdown(): (md: string) => Root export default function useMarkdown(): (md: string) => Root
@ -13,7 +14,7 @@ export default function useMarkdown(): (md: string) => Root
const parse = (markdown: string) => { const parse = (markdown: string) => {
if (!processor) if (!processor)
{ {
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkRehype]); processor = unified().use([RemarkParse, RemarkBreaks, RemarkGfm, RemarkOfm, RemarkRehype]);
} }
const processed = processor.runSync(processor.parse(markdown)) as Root; const processed = processor.runSync(processor.parse(markdown)) as Root;

BIN
db.sqlite

Binary file not shown.

69
layouts/default.vue Normal file
View File

@ -0,0 +1,69 @@
<script lang="ts">
let icon: HTMLDivElement | null;
function toggleLeftPanel(_: Event): void {
document.querySelector('.published-container')?.classList.toggle('is-left-column-open');
}
function hideLeftPanel(_: Event): void {
document.querySelector('.published-container')?.classList.remove('is-left-column-open');
}
</script>
<script setup lang="ts">
const { id: project, home, get } = useProject();
if(project.value !== 0 && home.value === null)
{
await useAsyncData(`project:get:${project}`, get);
}
const toggled = ref(false);
onMounted(() => {
icon = document.querySelector('.site-nav-bar .clickable-icon');
icon?.removeEventListener('click', toggleLeftPanel);
icon?.addEventListener('click', toggleLeftPanel);
});
</script>
<template>
<div class="site-nav-bar">
<div>
<div class="clickable-icon">
<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-menu">
<line x1="4" y1="12" x2="20" y2="12"></line>
<line x1="4" y1="6" x2="20" y2="6"></line>
<line x1="4" y1="18" x2="20" y2="18"></line>
</svg>
</div>
<div class="gapx-3 flex align-stretch">
<NuxtLink @click="hideLeftPanel" class="site-nav-bar-text" aria-label="Accueil" :to="{ path: '/', force: true }">
<ThemeIcon icon="logo" :width=40 :height=40 />
</NuxtLink>
<div class="site-nav-bar-text mobile-hidden">
<NuxtLink aria-label="Projet" :to="{ path: project === 0 ? `/explorer` : `/explorer/${project}${home}`, force: true }"
:class="{'mod-active': $route.path.startsWith('/explorer')}">Projet</NuxtLink>
<div class="arrow-down" :class="{active: toggled}" @click="toggled = !toggled"></div>
<div class="arrow-group" @click="toggled = false">
<NuxtLink class="arrow-group-item" aria-label="Projets" :to="{ path: '/explorer', force: true }">Liste des projets</NuxtLink>
</div>
</div>
<NuxtLink class="site-nav-bar-text mobile-hidden" aria-label="Editeur" :to="{ path: '/editing', force: true }"
:class="{'mod-active': $route.path.startsWith('/editing')}">Editeur</NuxtLink>
</div>
</div>
<div class="mobile-bigger">
<SearchView />
</div>
<div class="ps-1 gapx-1 flex align-center">
<ThemeSwitch class="mobile-hidden" />
<NuxtLink class="site-login" :to="{ path: '/user/profile', force: true }">
<ThemeIcon icon="user" :width=32 :height=32 />
</NuxtLink>
</div>
</div>
<div class="site-body">
<slot />
</div>
</template>

70
layouts/explorer.vue Normal file
View File

@ -0,0 +1,70 @@
<script lang="ts">
let icon: HTMLDivElement | null;
function toggleLeftPanel(_: Event): void {
document.querySelector('.published-container')?.classList.toggle('is-left-column-open');
}
function hideLeftPanel(_: Event): void {
document.querySelector('.published-container')?.classList.remove('is-left-column-open');
}
</script>
<script setup lang="ts">
const { id: project, home, get } = useProject();
if(project.value !== 0 && home.value === null)
{
await useAsyncData(`project:get:${project}`, get);
}
const toggled = ref(false);
onMounted(() => {
icon = document.querySelector('.site-nav-bar .clickable-icon');
icon?.removeEventListener('click', toggleLeftPanel);
icon?.addEventListener('click', toggleLeftPanel);
});
</script>
<template>
<div class="site-nav-bar">
<div>
<div class="clickable-icon">
<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-menu">
<line x1="4" y1="12" x2="20" y2="12"></line>
<line x1="4" y1="6" x2="20" y2="6"></line>
<line x1="4" y1="18" x2="20" y2="18"></line>
</svg>
</div>
<div class="gapx-3 flex align-stretch">
<NuxtLink @click="hideLeftPanel" class="site-nav-bar-text" aria-label="Accueil" :to="{ path: '/', force: true }">
<ThemeIcon icon="logo" :width=40 :height=40 />
</NuxtLink>
<div class="site-nav-bar-text mobile-hidden">
<NuxtLink aria-label="Projet" :to="{ path: project === 0 ? `/explorer` : `/explorer/${project}${home}`, force: true }"
:class="{'mod-active': $route.path.startsWith('/explorer')}">Projet</NuxtLink>
<div class="arrow-down" :class="{active: toggled}" @click="toggled = !toggled"></div>
<div class="arrow-group" @click="toggled = false">
<NuxtLink class="arrow-group-item" aria-label="Projets" :to="{ path: '/explorer', force: true }">Liste des projets</NuxtLink>
</div>
</div>
<NuxtLink class="site-nav-bar-text mobile-hidden" aria-label="Editeur" :to="{ path: '/editing', force: true }"
:class="{'mod-active': $route.path.startsWith('/editing')}">Editeur</NuxtLink>
</div>
</div>
<div class="mobile-bigger">
<SearchView />
</div>
<div class="ps-1 gapx-1 flex align-center">
<ThemeSwitch class="mobile-hidden" />
<NuxtLink class="site-login" :to="{ path: '/user/profile', force: true }">
<ThemeIcon icon="user" :width=32 :height=32 />
</NuxtLink>
</div>
</div>
<div class="site-body">
<ExplorerNavigation></ExplorerNavigation>
<slot></slot>
</div>
</template>

View File

@ -1,4 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({
layout: 'explorer'
})
const route = useRoute(); const route = useRoute();
const { data: content } = await useFetch(`/api/project/${route.params.projectId}/file`, { const { data: content } = await useFetch(`/api/project/${route.params.projectId}/file`, {
@ -6,10 +10,6 @@ const { data: content } = await useFetch(`/api/project/${route.params.projectId}
path: unifySlug(route.params.slug) path: unifySlug(route.params.slug)
} }
}); });
const { set } = useProject();
await set(parseInt(route.params.projectId as string));
</script> </script>
<template> <template>
@ -24,14 +24,14 @@ await set(parseInt(route.params.projectId as string));
<div class="markdown-preview-view markdown-rendered node-insert-event hide-title"> <div class="markdown-preview-view markdown-rendered node-insert-event hide-title">
<div class="markdown-preview-sizer markdown-preview-section" style="padding-bottom: 0px;"> <div class="markdown-preview-sizer markdown-preview-section" style="padding-bottom: 0px;">
<h1>{{ content[0].title }}</h1> <h1>{{ content[0].title }}</h1>
<Markdown v-model="content[0].content" /> <Markdown v-model="content[0].content"></Markdown>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<RightComponent/> <CommentSide></CommentSide>
</template> </template>
<CanvasRenderer v-else-if="!!content && content[0] && content[0].type == 'Canvas' && !!content[0].content" :canvas="JSON.parse(content[0].content)" /> <CanvasRenderer v-else-if="!!content && content[0] && content[0].type == 'Canvas' && !!content[0].content" :canvas="JSON.parse(content[0].content)"></CanvasRenderer>
<div v-else-if="!!content && content[0]"> <div v-else-if="!!content && content[0]">
<div class="not-found-container"> <div class="not-found-container">
<div class="not-found-image"></div> <div class="not-found-image"></div>

View File

@ -1,89 +0,0 @@
<!--<script lang="ts">
let icon: HTMLDivElement | null;
function toggleLeftPanel(_: Event) {
icon!.parentElement!.parentElement!.parentElement!.parentElement!.classList.toggle('is-left-column-open');
}
</script>
<script setup lang="ts">
const route = useRoute();
const tag = computed(() => {
return Array.isArray(route.params.slug) ? route.params.slug.join('/') : route.params.slug;
});
const descriptions = inject('tags/descriptions') as ParsedContent;
function sluggify(s: string): string {
return s
.split("/")
.map((segment) => segment.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/^\d\. */g, '').replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q").toLowerCase()) // slugify all segments
.filter(e => !!e)
.join("/") // always use / as sep
.replace(/\/$/, "")
}
function flatten(val: TocLink[]): TocLink[] {
return val.flatMap ? val?.flatMap((e: TocLink) => e.children ? [e, ...flatten(e.children)] : e) : val;
}
function getContent(content: ParsedContent)
{
const body = JSON.parse(JSON.stringify(content.body)) as MarkdownRoot;
const id = flatten(body?.toc?.links ?? []).find(e => sluggify(e.id) === tag.value.replaceAll('/', ''));
const htag = `h${id?.depth ?? 0}`;
const startIdx = body.children.findIndex(e => e.tag === htag && e?.props?.id === id?.id) ?? 0;
const nbr = body.children.findIndex((e, i) => i > startIdx && e.tag?.match(new RegExp(`h[1-${id?.depth ?? 1}]`)));
body.children = [...body.children].splice(startIdx + 1, (nbr === -1 ? body.children.length : nbr) - (startIdx + 1));
return { ... content, body };
}
onMounted(() => {
document.querySelectorAll('.callout.is-collapsible .callout-title').forEach(e => {
e.addEventListener('click', (_) => {
e.parentElement!.classList.toggle('is-collapsed');
})
})
icon = document.querySelector('.site-header .clickable-icon');
icon?.removeEventListener('click', toggleLeftPanel);
icon?.addEventListener('click', toggleLeftPanel);
})
</script>
<style>
.card-container {
display: flex;
flex-wrap: wrap;
}
</style>
<template>
<LeftComponent />
<ContentList :query="{ where: { frontmatter: { tags: { $contains: tag }}}}">
<template #default="{ list }">
<div class="render-container-inner">
<div class="publish-renderer">
<div class="markdown-preview-view markdown-rendered node-insert-event hide-title">
<div class="markdown-preview-sizer markdown-preview-section" style="padding-bottom: 0px;">
<h1>#{{ tag }}</h1>
<ContentRenderer :value="getContent(descriptions)" />
<h2>Pages contenant le tag</h2>
<div class="card-container">
<NuxtLink :key="item._id" :to="{ path: '/explorer' + item._path, force: true }" v-for="item of list" class="tag"> {{ item.title }} </NuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<template #not-found>
<div class="not-found-container">
<div class="not-found-image"></div>
<div class="not-found-title">Introuvable</div>
<div class="not-found-description">Cette page n'existe pas</div>
</div>
</template>
</ContentList>
</template>-->
<template></template>

View File

@ -3,9 +3,9 @@ const data = `### Librairies, framework et outils libres utilisés:
- [Vuejs](https://vuejs.org/) - MIT License - Copyright (c) 2018-present, Yuxi (Evan) You and Vue contributors - [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 - [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](https://nuxt.com/) - MIT License - Copyright (c) 2016-present - Nuxt Team
- [NuxtAuth](https://auth.sidebase.io/) - MIT License - Copyright (c) 2022 SIDESTREAM GmbH - [nuxt-auth-utils](https://github.com/atinux/nuxt-auth-utils) - MIT License - Copyright (c) 2023 Sébastien Chopin
Le logo a été créé grâce aux icones de [Game Icons](https://game-icons.net).`; Le logo a été créé grace aux icones de [Game Icons](https://game-icons.net).`;
</script> </script>
<template> <template>

View File

@ -1,4 +1,5 @@
import useDatabase from '~/composables/useDatabase'; import useDatabase from '~/composables/useDatabase';
import type { File } from '~/types/api';
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const project = getRouterParam(e, "projectId"); const project = getRouterParam(e, "projectId");

9
types/api.d.ts vendored
View File

@ -29,16 +29,19 @@ export interface Navigation {
children?: Navigation[]; children?: Navigation[];
} }
export interface File { export interface File {
id: number;
project: number; project: number;
path: string; path: string;
owner: number;
title: string; title: string;
order: number;
type: 'Markdown' | 'Canvas' | 'File' | 'Folder'; type: 'Markdown' | 'Canvas' | 'File' | 'Folder';
content: string; content: string;
owner: number; navigable: boolean;
private: boolean;
} }
export interface Comment { export interface Comment {
file: number; project: number;
path: number;
user_id: number; user_id: number;
sequence: number; sequence: number;
position: number; position: number;