Update DB schema to include an ID and split overview and content. Progressing on ContentEditor with the ID fixing many issues. Adding modal and sync features.

This commit is contained in:
Peaceultime 2025-03-31 01:19:58 +02:00
parent 227d7224e5
commit 1d41514b26
48 changed files with 922 additions and 1156 deletions

View File

@ -5,6 +5,7 @@
"name": "d-any",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.5.0",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@codemirror/lang-markdown": "^6.3.2",
"@floating-ui/dom": "^1.6.13",
@ -67,6 +68,8 @@
"@atlaskit/pragmatic-drag-and-drop": ["@atlaskit/pragmatic-drag-and-drop@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.0.0", "bind-event-listener": "^3.0.0", "raf-schd": "^4.0.3" } }, "sha512-VnHcgOBALm+mbL9CoJPI6wBNQeB0is+CkejdfAlaP8RfBoELe+0sQtE8j4Z4fPRqDzo11OEqUYKHkmx4Ttzozg=="],
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": ["@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.0", "", { "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.4.0", "@babel/runtime": "^7.0.0" } }, "sha512-E52y8/0BTTf4ai6BJyFYgdVHFgQ1AES33KvAVQpZ41jMkoukLIq6UoCudOXku7xs3qoPygQdpC+vitVUuEFJXw=="],
"@atlaskit/pragmatic-drag-and-drop-hitbox": ["@atlaskit/pragmatic-drag-and-drop-hitbox@1.0.3", "", { "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.1.0", "@babel/runtime": "^7.0.0" } }, "sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA=="],
"@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="],

View File

@ -29,12 +29,6 @@ type EdgeEditor = InstanceType<typeof CanvasEdgeEditor>;
const cancelEvent = (e: Event) => e.preventDefault();
const stopPropagation = (e: Event) => e.stopImmediatePropagation();
function getID(length: number)
{
for (var id = [], i = 0; i < length; i++)
id.push((16 * Math.random() | 0).toString(16));
return id.join("");
}
function center(touches: TouchList): Position
{
const pos = { x: 0, y: 0 };
@ -58,7 +52,7 @@ function distance(touches: TouchList): number
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { clamp } from '#shared/general.util';
import { clamp, getID, ID_SIZE } from '#shared/general.util';
import type { CanvasContent, CanvasEdge, CanvasNode } from '~/types/canvas';
const canvas = defineModel<CanvasContent>({ required: true });
@ -342,11 +336,10 @@ function edit(element: Element)
}
function createNode(e: MouseEvent)
{
const centerX = (viewportSize.right.value - viewportSize.left.value) / 2 + viewportSize.left.value, centerY = (viewportSize.bottom.value - viewportSize.top.value) / 2 + viewportSize.top.value;
const width = 250, height = 100;
const x = e.layerX / zoom.value - dispX.value - width / 2;
const y = e.layerY / zoom.value - dispY.value - height / 2;
const node: CanvasNode = { id: getID(16), x, y, width, height, type: 'text' };
const node: CanvasNode = { id: getID(ID_SIZE), x, y, width, height, type: 'text' };
if(!canvas.value.nodes)
canvas.value.nodes = [node];
@ -412,7 +405,7 @@ function dragEndEdgeTo(e: MouseEvent): void
if(fakeEdge.value.snapped)
{
const node = canvas.value.nodes!.find(e => e.id === fakeEdge.value.drag!.id)!;
const edge: CanvasEdge = { fromNode: fakeEdge.value.drag!.id, fromSide: fakeEdge.value.fromSide!, toNode: fakeEdge.value.snapped.node, toSide: fakeEdge.value.snapped.side, id: getID(16), color: node.color };
const edge: CanvasEdge = { fromNode: fakeEdge.value.drag!.id, fromSide: fakeEdge.value.fromSide!, toNode: fakeEdge.value.snapped.node, toSide: fakeEdge.value.snapped.side, id: getID(ID_SIZE), color: node.color };
canvas.value.edges?.push(edge);
addAction('create', [{ from: undefined, to: edge, element: { id: edge.id, type: 'edge' } }]);

View File

@ -1,276 +0,0 @@
<script lang="ts">
import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, WidgetType, type DecorationSet } from '@codemirror/view';
import { Annotation, EditorState, RangeValue, SelectionRange, type Range } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, defaultHighlightStyle, foldKeymap, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
import { search, searchKeymap } from '@codemirror/search';
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { IterMode, Tree } from '@lezer/common';
import { tags } from '@lezer/highlight';
const External = Annotation.define<boolean>();
const Hidden = Decoration.mark({ class: 'hidden' });
const Bullet = Decoration.mark({ class: '*:hidden before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4' });
const Blockquote = Decoration.line({ class: '*:hidden before:block !ps-4 relative before:absolute before:top-0 before:bottom-0 before:left-0 before:w-1 before:bg-none before:bg-light-30 dark:before:bg-dark-30' });
const TagTag = tags.special(tags.content);
const intersects = (a: {
from: number;
to: number;
}, b: {
from: number;
to: number;
}) => !(a.to < b.from || b.to < a.from);
const highlight = HighlightStyle.define([
{ tag: tags.heading1, class: 'text-5xl pt-4 pb-2 after:hidden' },
{ tag: tags.heading2, class: 'text-4xl pt-4 pb-2 ps-1 leading-loose after:hidden' },
{ tag: tags.heading3, class: 'text-2xl font-bold pt-1 after:hidden' },
{ tag: tags.heading4, class: 'text-xl font-semibold pt-1 after:hidden variant-cap' },
{ tag: tags.meta, color: "#404740" },
{ tag: tags.link, textDecoration: "underline" },
{ tag: tags.heading, textDecoration: "underline", fontWeight: "bold" },
{ tag: tags.emphasis, fontStyle: "italic" },
{ tag: tags.strong, fontWeight: "bold" },
{ tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: tags.keyword, color: "#708" },
{ tag: TagTag, class: 'cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30' }
]);
class Decorator
{
static hiddenNodes: string[] = [
'HardBreak',
'LinkMark',
'EmphasisMark',
'CodeMark',
'CodeInfo',
'URL',
]
decorations: DecorationSet;
constructor(view: EditorView)
{
this.decorations = Decoration.set(this.iterate(syntaxTree(view.state), view.visibleRanges, []), true);
}
update(update: ViewUpdate)
{
if(!update.docChanged && !update.viewportChanged && !update.selectionSet)
return;
this.decorations = this.decorations.update({
filter: (f, t, v) => false,
add: this.iterate(syntaxTree(update.state), update.view.visibleRanges, update.state.selection.ranges),
sort: true,
});
}
iterate(tree: Tree, visible: readonly {
from: number;
to: number;
}[], selection: readonly SelectionRange[]): Range<Decoration>[]
{
const decorations: Range<Decoration>[] = [];
for (let { from, to } of visible) {
tree.iterate({
from, to, mode: IterMode.IgnoreMounts,
enter: node => {
if(node.node.parent && selection.some(e => intersects(e, node.node.parent!)))
return true;
else if(node.name === 'HeaderMark')
decorations.push(Hidden.range(node.from, node.to + 1));
else if(Decorator.hiddenNodes.includes(node.name))
decorations.push(Hidden.range(node.from, node.to));
else if(node.matchContext(['BulletList', 'ListItem']) && node.name === 'ListMark')
decorations.push(Bullet.range(node.from, node.to + 1));
else if(node.matchContext(['Blockquote']))
decorations.push(Blockquote.range(node.from, node.from));
return true;
},
});
}
return decorations;
}
}
</script>
<script setup lang="ts">
const { autofocus = false } = defineProps<{
placeholder?: string
autofocus?: boolean
}>();
const model = defineModel<string>();
const editor = useTemplateRef('editor');
const view = ref<EditorView>();
onMounted(() => {
if(editor.value)
{
view.value = new EditorView({
doc: model.value,
parent: editor.value,
extensions: [
markdown({
base: markdownLanguage,
extensions: {
defineNodes: [
{ name: "Tag", style: TagTag },
{ name: "TagMark", style: tags.processingInstruction }
],
parseInline: [{
name: "Tag",
parse(cx, next, pos) {
if (next != 35 || cx.char(pos + 1) == 35) return -1;
let elts = [cx.elt("TagMark", pos, pos + 1)];
for (let i = pos + 1; i < cx.end; i++) {
let next = cx.char(i);
if (next == 35)
return cx.addElement(cx.elt("Tag", pos, i + 1, elts.concat(cx.elt("TagMark", i, i + 1))));
if (next == 92)
elts.push(cx.elt("Escape", i, i++ + 2));
if (next == 32 || next == 9 || next == 10 || next == 13) break;
}
return -1
}
}],
}
}),
history(),
search(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(highlight),
bracketMatching(),
closeBrackets(),
crosshairCursor(),
EditorView.lineWrapping,
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap
]),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
{
model.value = viewUpdate.state.doc.toString();
}
}),
EditorView.contentAttributes.of({spellcheck: "true"}),
ViewPlugin.fromClass(Decorator, {
decorations: e => e.decorations,
})
]
});
if(autofocus)
{
view.value.focus();
}
}
});
onBeforeUnmount(() => {
if (view.value)
{
view.value?.destroy();
view.value = undefined;
}
});
watchEffect(() => {
if (model.value === void 0) {
return;
}
const currentValue = view.value ? view.value.state.doc.toString() : "";
if (view.value && model.value !== currentValue) {
view.value.dispatch({
changes: { from: 0, to: currentValue.length, insert: model.value || "" },
annotations: [External.of(true)],
});
}
});
defineExpose({ focus: () => editor.value?.focus() });
</script>
<template>
<div ref="editor" class="flex flex-1 w-full justify-stretch items-stretch py-2 px-1.5 font-sans text-base"></div>
</template>
<style>
.CodeMirror
{
@apply bg-transparent;
@apply flex-1 h-full;
@apply font-sans;
@apply text-light-100 dark:text-dark-100;
}
.cancel-gutters .CodeMirror-gutters
{
@apply hidden;
}
.CodeMirror-sizer
{
@apply !px-3;
}
.cancel-gutters .CodeMirror-sizer
{
@apply ms-2;
}
.CodeMirror-gutters
{
@apply bg-transparent;
@apply border-transparent;
}
.CodeMirror-gutter-wrapper
{
@apply absolute top-0 bottom-0;
@apply flex justify-center items-center;
}
.CodeMirror-foldmarker
{
@apply text-light-100;
@apply dark:text-dark-100;
@apply ps-3;
text-shadow: none;
}
.hmd-inactive-line .cm-formatting-header, .hmd-inactive-line .cm-formatting-link, .hmd-inactive-line .cm-link-has-alias, .hmd-inactive-line .cm-link-alias-pipe
{
@apply hidden;
}
.CodeMirror-line
{
@apply text-base;
}
.CodeMirror-cursor
{
@apply border-light-100 dark:border-dark-100;
}
.CodeMirror-selected
{
@apply bg-light-35 dark:bg-dark-35;
}
.HyperMD-list-line-1 {
@apply !ps-0;
}
.HyperMD-list-line-2 {
@apply !ps-6;
}
.HyperMD-list-line-3 {
@apply !ps-12;
}
</style>

View File

@ -1,40 +0,0 @@
<template>
<Editor ref="editor" v-model="model" autofocus :gutters="false" />
<iframe ref="iframe" class="w-full h-full border-0" sandbox="allow-same-origin allow-scripts"></iframe>
</template>
<script setup lang="ts">
const model = defineModel<string>();
const editor = useTemplateRef('editor'), iframe = useTemplateRef('iframe');
onMounted(() => {
if(iframe.value && iframe.value.contentDocument && editor.value)
{
editor.value.$el.remove();
iframe.value.contentDocument.documentElement.setAttribute('class', document.documentElement.getAttribute('class') ?? '');
iframe.value.contentDocument.documentElement.setAttribute('style', document.documentElement.getAttribute('style') ?? '');
const base = iframe.value.contentDocument.head.appendChild(iframe.value.contentDocument.createElement('base'));
base.setAttribute('href', window.location.href);
for(let element of document.getElementsByTagName('link'))
{
if(element.getAttribute('rel') === 'stylesheet')
iframe.value.contentDocument.head.appendChild(element.cloneNode(true));
}
for(let element of document.getElementsByTagName('style'))
{
iframe.value.contentDocument.head.appendChild(element.cloneNode(true));
}
iframe.value.contentDocument.body.setAttribute('class', document.body.getAttribute('class') ?? '');
iframe.value.contentDocument.body.setAttribute('style', document.body.getAttribute('style') ?? '');
iframe.value.contentDocument.body.appendChild(editor.value.$el);
editor.value.focus();
}
});
</script>

View File

@ -1,80 +0,0 @@
<template>
<div ref="element"></div>
</template>
<script setup lang="ts">
import { heading } from 'hast-util-heading';
import { headingRank } from 'hast-util-heading-rank';
import { parseId } from '~/shared/general.util';
import type { Root } from 'hast';
import { renderMarkdown } from '~/shared/markdown.util';
import { tag, a, blockquote, h1, h2, h3, h4, h5, hr, li, small, table, td, th, type Prose } from '~/shared/proses';
import { callout } from '../shared/proses';
const { content, proses, filter } = defineProps<{
content: string
proses?: Record<string, Prose>
filter?: string
}>();
const element = useTemplateRef('element');
const parser = useMarkdown(), data = ref<Root>();
const node = computed(() => content ? parser.parseSync(content) : undefined);
watch([node], () => {
if(!node.value)
{
data.value = undefined;
return;
}
else if(!filter)
{
data.value = node.value;
}
else
{
const start = node.value?.children.findIndex(e => heading(e) && parseId(e.properties.id as string | undefined) === filter) ?? -1;
if(start === -1)
data.value = node.value;
else
{
let end = start;
const rank = headingRank(node.value.children[start])!;
while(end < node.value.children.length)
{
end++;
if(heading(node.value.children[end]) && headingRank(node.value.children[end])! <= rank)
break;
}
data.value = { ...node.value, children: node.value.children.slice(start, end) };
}
}
mount();
}, { immediate: true, });
onMounted(() => {
mount();
})
function mount()
{
if(!element.value || !data.value)
return;
const el = element.value!;
for(let i = el.childElementCount - 1; i >= 0; i--)
{
el.removeChild(el.children[i]);
}
el.appendChild(renderMarkdown(data.value, { tag, a, blockquote, callout, h1, h2, h3, h4, h5, hr, li, small, table, td, th, ...proses }));
const hash = useRouter().currentRoute.value.hash;
if(hash.length > 0)
{
document.getElementById(parseId(hash.substring(1))!)?.scrollIntoView({ behavior: 'instant' })
}
}
</script>

View File

@ -1,115 +0,0 @@
<script lang="ts">
import type { RootContent, Root } from 'hast';
import { Text, Comment } from 'vue';
import ProseP from '~/components/prose/ProseP.vue';
import ProseA from '~/components/prose/ProseA.vue';
import ProseBlockquote from '~/components/prose/ProseBlockquote.vue';
import ProseCallout from './prose/ProseCallout.vue';
import ProseCode from '~/components/prose/ProseCode.vue';
import ProsePre from '~/components/prose/ProsePre.vue';
import ProseEm from '~/components/prose/ProseEm.vue';
import ProseH1 from '~/components/prose/ProseH1.vue';
import ProseH2 from '~/components/prose/ProseH2.vue';
import ProseH3 from '~/components/prose/ProseH3.vue';
import ProseH4 from '~/components/prose/ProseH4.vue';
import ProseH5 from '~/components/prose/ProseH5.vue';
import ProseH6 from '~/components/prose/ProseH6.vue';
import ProseHr from '~/components/prose/ProseHr.vue';
import ProseImg from '~/components/prose/ProseImg.vue';
import ProseUl from '~/components/prose/ProseUl.vue';
import ProseOl from '~/components/prose/ProseOl.vue';
import ProseLi from '~/components/prose/ProseLi.vue';
import ProseSmall from './prose/ProseSmall.vue';
import ProseStrong from '~/components/prose/ProseStrong.vue';
import ProseTable from '~/components/prose/ProseTable.vue';
import ProseTag from '~/components/prose/ProseTag.vue';
import ProseThead from '~/components/prose/ProseThead.vue';
import ProseTbody from '~/components/prose/ProseTbody.vue';
import ProseTd from '~/components/prose/ProseTd.vue';
import ProseTh from '~/components/prose/ProseTh.vue';
import ProseTr from '~/components/prose/ProseTr.vue';
import ProseScript from '~/components/prose/ProseScript.vue';
const proseList = {
"p": ProseP,
"a": ProseA,
"blockquote": ProseBlockquote,
"callout": ProseCallout,
"code": ProseCode,
"pre": ProsePre,
"em": ProseEm,
"h1": ProseH1,
"h2": ProseH2,
"h3": ProseH3,
"h4": ProseH4,
"h5": ProseH5,
"h6": ProseH6,
"hr": ProseHr,
"img": ProseImg,
"ul": ProseUl,
"ol": ProseOl,
"li": ProseLi,
"small": ProseSmall,
"strong": ProseStrong,
"table": ProseTable,
"tag": ProseTag,
"thead": ProseThead,
"tbody": ProseTbody,
"td": ProseTd,
"th": ProseTh,
"tr": ProseTr,
"script": ProseScript
};
export default defineComponent({
name: 'MarkdownRenderer',
props: {
node: {
type: Object,
required: true
},
proses: {
type: Object,
default: () => ({})
}
},
async setup(props) {
if(props.proses)
{
for(const prose of Object.keys(props.proses))
{
if(typeof props.proses[prose] === 'string')
props.proses[prose] = await resolveComponent(props.proses[prose]);
}
}
return { tags: Object.assign({}, proseList, props.proses) };
},
render(ctx: any) {
const { node, tags } = ctx;
if(!node)
return null;
return h('div', null, {default: () => (node as Root).children.map(e => renderNode(e, tags)).filter(e => !!e)});
}
});
function renderNode(node: RootContent, tags: Record<string, any>): VNode | undefined
{
if(node.type === 'text' && node.value.length > 0 && node.value !== '\n')
{
return h(Text, node.value);
}
else if(node.type === 'comment' && node.value.length > 0 && node.value !== '\n')
{
return h(Comment, node.value);
}
else if(node.type === 'element')
{
return h(tags[node.tagName] ?? node.tagName, { ...node.properties, class: node.properties.className }, { default: () => node.children.map(e => renderNode(e, tags)).filter(e => !!e) });
}
return undefined;
}
</script>

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,7 @@
import { relations } from 'drizzle-orm';
import { int, text, sqliteTable, type SQLiteTableExtraConfig, primaryKey, blob } from 'drizzle-orm/sqlite-core';
import { int, text, sqliteTable as table, primaryKey, blob } from 'drizzle-orm/sqlite-core';
export const usersTable = sqliteTable("users", {
export const usersTable = table("users", {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
email: text().notNull().unique(),
@ -9,46 +9,41 @@ export const usersTable = sqliteTable("users", {
state: int().notNull().default(0),
});
export const usersDataTable = sqliteTable("users_data", {
export const usersDataTable = table("users_data", {
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
lastTimestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
logCount: int().notNull().default(0),
});
export const userSessionsTable = sqliteTable("user_sessions", {
export const userSessionsTable = table("user_sessions", {
id: int().notNull(),
user_id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}, (table): SQLiteTableExtraConfig => {
return {
pk: primaryKey({ columns: [table.id, table.user_id] }),
}
});
}, (table) => [ primaryKey({ columns: [table.id, table.user_id] }) ]);
export const userPermissionsTable = sqliteTable("user_permissions", {
export const userPermissionsTable = table("user_permissions", {
id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
permission: text().notNull(),
}, (table): SQLiteTableExtraConfig => {
return {
pk: primaryKey({ columns: [table.id, table.permission] }),
}
});
}, (table) => [ primaryKey({ columns: [table.id, table.permission] }) ]);
export const explorerContentTable = sqliteTable("explorer_content", {
path: text().primaryKey(),
export const projectFilesTable = table("project_files", {
id: text().primaryKey(),
path: text().notNull().unique(),
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
title: text().notNull(),
type: text({ enum: ['file', 'folder', 'markdown', 'canvas', 'map'] }).notNull(),
content: blob({ mode: 'buffer' }),
navigable: int({ mode: 'boolean' }).notNull().default(true),
private: int({ mode: 'boolean' }).notNull().default(false),
order: int().notNull(),
visit: int().notNull().default(0),
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
export const emailValidationTable = sqliteTable("email_validation", {
export const projectContentTable = table("project_content", {
id: text().primaryKey(),
content: blob({ mode: 'buffer' }),
});
export const emailValidationTable = table("email_validation", {
id: text().primaryKey(),
timestamp: int({ mode: 'timestamp' }).notNull(),
})
@ -57,7 +52,7 @@ export const usersRelation = relations(usersTable, ({ one, many }) => ({
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
session: many(userSessionsTable),
permission: many(userPermissionsTable),
content: many(explorerContentTable),
files: many(projectFilesTable),
}));
export const usersDataRelation = relations(usersDataTable, ({ one }) => ({
users: one(usersTable, { fields: [usersDataTable.id], references: [usersTable.id], }),
@ -68,6 +63,9 @@ export const userSessionsRelation = relations(userSessionsTable, ({ one }) => ({
export const userPermissionsRelation = relations(userPermissionsTable, ({ one }) => ({
users: one(usersTable, { fields: [userPermissionsTable.id], references: [usersTable.id], }),
}));
export const explorerContentRelation = relations(explorerContentTable, ({ one }) => ({
users: one(usersTable, { fields: [explorerContentTable.owner], references: [usersTable.id], }),
export const projectFilesRelation = relations(projectFilesTable, ({ one }) => ({
users: one(usersTable, { fields: [projectFilesTable.owner], references: [usersTable.id], }),
}));
export const projectContentRelation = relations(projectContentTable, ({ one }) => ({
files: one(projectFilesTable, { fields: [projectContentTable.id], references: [projectFilesTable.id], }),
}));

View File

@ -0,0 +1,21 @@
CREATE TABLE `project_content` (
`id` text PRIMARY KEY NOT NULL,
`content` blob
);
--> statement-breakpoint
CREATE TABLE `project_files` (
`id` text PRIMARY KEY NOT NULL,
`path` text NOT NULL,
`owner` integer NOT NULL,
`title` text NOT NULL,
`type` text NOT NULL,
`navigable` integer DEFAULT true NOT NULL,
`private` integer DEFAULT false NOT NULL,
`order` integer NOT NULL,
`timestamp` integer NOT NULL,
FOREIGN KEY (`owner`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_files_path_unique` ON `project_files` (`path`);--> statement-breakpoint
DROP TABLE `explorer_content`;--> statement-breakpoint
ALTER TABLE `users_data` DROP COLUMN `logCount`;

View File

@ -0,0 +1,375 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b24a44ad-b43a-4aba-be9c-350fd41bed04",
"prevId": "a2731c1f-4150-4423-946e-670d794f8961",
"tables": {
"email_validation": {
"name": "email_validation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_content": {
"name": "project_content",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_files": {
"name": "project_files",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"project_files_path_unique": {
"name": "project_files_path_unique",
"columns": [
"path"
],
"isUnique": true
}
},
"foreignKeys": {
"project_files_owner_users_id_fk": {
"name": "project_files_owner_users_id_fk",
"tableFrom": "project_files",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastTimestamp": {
"name": "lastTimestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -43,6 +43,13 @@
"when": 1734426608563,
"tag": "0005_panoramic_slayback",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1743344302223,
"tag": "0006_luxuriant_blade",
"breakpoints": true
}
]
}

View File

@ -1,6 +1,6 @@
<template>
<CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open">
<div class="z-40 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
<div class="z-30 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
<div class="flex items-center px-2 gap-4">
<CollapsibleTrigger asChild>
<Button icon class="!bg-transparent group md:hidden">

View File

@ -8,6 +8,7 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.5.0",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@codemirror/lang-markdown": "^6.3.2",
"@floating-ui/dom": "^1.6.13",

View File

@ -3,7 +3,7 @@
<Title>d[any] - Modification</Title>
</Head>
<div class="flex flex-1 flex-col xl:-mx-12 xl:-my-8 lg:-mx-8 lg:-my-6 -mx-6 -my-3 overflow-hidden">
<div class="z-50 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
<div class="z-30 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
<div class="flex items-center px-2 gap-4">
<!-- <CollapsibleTrigger asChild>
<Button icon class="!bg-transparent group md:hidden">
@ -36,22 +36,54 @@
<script setup lang="ts">
import { Content, Editor } from '#shared/content.util';
import { loading } from '#shared/proses';
import { button, loading } from '#shared/proses';
import { dom, icon, text } from '~/shared/dom.util';
import { modal, popper } from '~/shared/floating.util';
definePageMeta({
rights: ['admin', 'editor'],
layout: 'null',
});
const toaster = useToast();
const { user } = useUserSession();
const tree = useTemplateRef('tree'), container = useTemplateRef('container');
let editor: Editor;
function pull()
{
Content.pull().then(e => {
toaster.add({ type: 'success', content: 'Données mises à jour avec succès.', timer: true, duration: 7500 });
}).catch(e => {
toaster.add({ type: 'success', content: 'Une erreur est survenue durant la récupération des données.', timer: true, duration: 7500 });
console.error(e);
});
}
function push()
{
const { close } = modal([dom('div', { class: 'flex flex-col gap-4 justify-center items-center' }, [ dom('div', { class: 'text-xl', text: 'Mise à jour des données' }), loading('large') ])], { priority: false, closeWhenOutside: true, });
Content.push().then(e => {
close();
toaster.add({ type: 'success', content: 'Données mises à jour avec succès.', timer: true, duration: 7500 });
}).catch(e => {
close();
toaster.add({ type: 'success', content: 'Une erreur est survenue durant l\'enregistrement des données.', timer: true, duration: 7500 });
console.error(e);
});
}
onMounted(async () => {
if(tree.value && container.value && await Content.ready)
{
const load = loading('normal');
tree.value.appendChild(load);
const content = dom('div', { class: 'flex flex-row justify-start items-center gap-4 p-2' }, [
popper(button(icon('ph:cloud-arrow-down', { height: 20, width: 20 }), pull, 'p-1'), { placement: 'top', offset: 4, delay: 120, arrow: true, content: [text('Actualiser')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
popper(button(icon('ph:cloud-arrow-up', { height: 20, width: 20 }), push, 'p-1'), { placement: 'top', offset: 4, delay: 120, arrow: true, content: [text('Enregistrer')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
])
tree.value.insertBefore(content, load);
editor = new Editor();

View File

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

View File

@ -1,16 +0,0 @@
import { z } from "zod";
import { fileType } from "./file";
export const single = z.object({
path: z.string(),
owner: z.number().finite(),
title: z.string(),
type: fileType,
navigable: z.boolean(),
private: z.boolean(),
order: z.number().finite(),
});
export const table = z.array(single);
export type Navigation = z.infer<typeof table>;
export type NavigationItem = z.infer<typeof single>;

View File

@ -1,22 +1,16 @@
import { z } from "zod";
import { fileType } from "./file";
import { projectFilesTable } from "~/db/schema";
const baseItem = z.object({
export const Project = z.array(z.object({
id: z.string(),
path: z.string(),
parent: z.string(),
name: z.string(),
title: z.string(),
type: fileType,
type: z.enum(projectFilesTable.type.enumValues),
navigable: z.boolean(),
private: z.boolean(),
order: z.number().finite(),
content: z.string().optional().or(z.null()),
});
export const item: z.ZodType<ProjectItem> = baseItem.extend({
children: z.lazy(() => item.array().optional()),
});
export const project = z.array(item);
timestamp: z.string(),
}));
export type ProjectItem = z.infer<typeof baseItem> & {
children?: ProjectItem[]
};
export type ProjectType = z.infer<typeof Project>;
export type ProjectItemType = ProjectType[number];

View File

@ -1,10 +1,10 @@
import type { SitemapUrlInput } from '#sitemap/types'
import { explorerContentTable } from '~/db/schema';
import { projectFilesTable as files } from '~/db/schema';
import useDatabase from '~/composables/useDatabase';
export default defineSitemapEventHandler(() => {
const db = useDatabase();
const pages = db.select({ path: explorerContentTable.path, lastMod: explorerContentTable.timestamp, navigable: explorerContentTable.navigable, private: explorerContentTable.private, type: explorerContentTable.type }).from(explorerContentTable).all();
const pages = db.select({ path: files.path, lastMod: files.timestamp, navigable: files.navigable, private: files.private, type: files.type }).from(files).all();
return pages.filter(e => e.type !== 'folder' && e.navigable && !e.private && e.path.split('/').map((_, i, a) => a.slice(0, i).join('/')).every(p => !pages.find(_p => _p.path === p)?.private)).map(e => ({
loc: `/explore/${encodeURIComponent(e.path)}`,

View File

@ -1,7 +1,6 @@
import { ne, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { hasPermissions } from '~/shared/auth.util';
import { projectFilesTable } from '~/db/schema';
import { hasPermissions } from '#shared/auth.util';
export default defineEventHandler(async (e) => {
const session = await getUserSession(e);
@ -16,17 +15,15 @@ export default defineEventHandler(async (e) => {
const db = useDatabase();
const content = db.select({
path: explorerContentTable.path,
owner: explorerContentTable.owner,
title: explorerContentTable.title,
type: explorerContentTable.type,
size: sql<number>`CASE WHEN ${explorerContentTable.content} IS NULL THEN 0 ELSE length(${explorerContentTable.content}) END`.as('size'),
navigable: explorerContentTable.navigable,
private: explorerContentTable.private,
order: explorerContentTable.order,
visit: explorerContentTable.visit,
timestamp: explorerContentTable.timestamp,
}).from(explorerContentTable).all();
path: projectFilesTable.path,
owner: projectFilesTable.owner,
title: projectFilesTable.title,
type: projectFilesTable.type,
navigable: projectFilesTable.navigable,
private: projectFilesTable.private,
order: projectFilesTable.order,
timestamp: projectFilesTable.timestamp,
}).from(projectFilesTable).all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;

View File

@ -1,6 +1,4 @@
import { sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { userSessionsTable } from '~/db/schema';
import { hasPermissions } from '~/shared/auth.util';
export default defineEventHandler(async (e) => {

View File

@ -93,8 +93,6 @@ export default defineEventHandler(async (e): Promise<Return> => {
}
}) as UserSessionRequired);
db.update(usersDataTable).set({ logCount: user.data.logCount + 1 }).where(eq(usersDataTable.id, user.id)).run();
setResponseStatus(e, 201);
return { success: true, session: data };
}

View File

@ -71,7 +71,7 @@ export default defineEventHandler(async (e): Promise<Return> => {
db.insert(usersDataTable).values({ id: sql.placeholder('id') }).prepare().run({ id: id.id });
logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [], lastTimestamp: new Date(), logCount: 1 } }) 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: [], lastTimestamp: new Date() } }) as UserSessionRequired);
const emailId = Bun.hash('register' + id.id + hash, Date.now());
const timestamp = Date.now() + 1000 * 60 * 60;

View File

@ -71,7 +71,7 @@ export default defineEventHandler(async (e): Promise<Return> => {
db.insert(usersDataTable).values({ id: sql.placeholder('id') }).prepare().run({ id: id.id });
logSession(e, await setUserSession(e, { user: { id: id.id, username: body.data.username, email: body.data.email, state: 0, signin: new Date(), permissions: [], lastTimestamp: new Date(), logCount: 1 } }) 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: [], lastTimestamp: new Date() } }) as UserSessionRequired);
await runTask('mail', {
payload: {

View File

@ -1,37 +0,0 @@
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { schema } from '~/schemas/file';
import { parsePath } from '~/shared/general.util';
export default defineEventHandler(async (e) => {
const body = await readValidatedBody(e, schema.safeParse);
if(!body.success)
{
setResponseStatus(e, 403);
throw body.error;
}
const db = useDatabase();
const buffer = Buffer.from(convertToStorableLinks(body.data.content, db.select({ path: explorerContentTable.path }).from(explorerContentTable).all().map(e => e.path)), 'utf-8');
const content = db.insert(explorerContentTable).values({ ...body.data, content: buffer }).onConflictDoUpdate({ target: explorerContentTable.path, set: { ...body.data, content: buffer, timestamp: new Date() } });
if(content !== undefined)
{
return content;
}
setResponseStatus(e, 404);
return;
});
export function convertToStorableLinks(content: string, path: string[]): string
{
return content.replaceAll(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (e: string, a1?: string, a2?: string , a3?: string) => {
const parsed = parsePath(a1 ?? '%%%%----%%%%----%%%%');
const replacer = path.find(e => e.endsWith(parsed));
const value = `[[${a1 ? (replacer ?? '') : ''}${a2 ?? ''}${(!a3 && a1 && replacer !== parsed ? '|' + a1 : a3) ?? ''}]]`;
return value;
});
}

View File

@ -0,0 +1,47 @@
import { eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { projectFilesTable as files, projectContentTable as content } from '~/db/schema';
export default defineEventHandler(async (e) => {
try
{
const id = getRouterParam(e, "id") ?? '';
if(!id)
{
setResponseStatus(e, 404);
return;
}
const db = useDatabase();
const data = db.select({ content: sql<string>`cast(${content.content} as TEXT)`.as('content'), private: files.private, owner: files.owner, }).from(content).leftJoin(files, eq(content.id, files.id)).where(eq(content.id, id)).get();
if(data && data.content)
{
const session = await getUserSession(e);
if(!session || !session.user || session.user.id !== data.owner)
{
if(data.private)
{
setResponseStatus(e, 404);
return;
}
else
{
return data.content.replace(/%%(.+?)%%/g, "");
}
}
return data.content;
}
setResponseStatus(e, 404);
return;
}
catch(_e)
{
console.error(_e);
setResponseStatus(e, 500);
return;
}
});

View File

@ -0,0 +1,43 @@
import useDatabase from '~/composables/useDatabase';
import { hasPermissions } from '#shared/auth.util';
import { projectContentTable, projectFilesTable } from '~/db/schema';
import { eq } from 'drizzle-orm';
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
if(!user || !hasPermissions(user.permissions, ['admin', 'editor']))
{
throw createError({ statusCode: 401, statusText: 'Unauthorized' });
}
try
{
const id = getRouterParam(e, "id") ?? '';
const body = await readRawBody(e);
if(!id)
{
setResponseStatus(e, 404);
return;
}
const db = useDatabase();
const item = db.select({ id: projectFilesTable.id }).from(projectFilesTable).where(eq(projectFilesTable.id, id)).get();
if(!item)
{
setResponseStatus(e, 404);
return;
}
db.insert(projectContentTable).values({ id: id, content: body }).onConflictDoUpdate({ set: { content: body }, target: projectContentTable.id }).run();
db.update(projectFilesTable).set({ timestamp: new Date() }).where(eq(projectFilesTable.id, id)).run()
}
catch(_e)
{
console.error(_e);
setResponseStatus(e, 500);
return;
}
});

View File

@ -1,76 +0,0 @@
import { eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { Content } from '~/shared/content.util';
export default defineEventHandler(async (e) => {
try
{
const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
const query = getQuery(e);
if(!path)
{
setResponseStatus(e, 404);
return;
}
const db = useDatabase();
const content = db.select({
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
'private': explorerContentTable.private,
'type': explorerContentTable.type,
'owner': explorerContentTable.owner,
'visit': explorerContentTable.visit,
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
if(content != undefined && content.content != undefined)
{
const session = await getUserSession(e);
if(!session || !session.user || session.user.id !== content.owner)
{
if(content.private)
{
setResponseStatus(e, 404);
return;
}
else
{
content.content = content.content.replace(/%%(.+)%%/g, "");
}
}
if(query?.type === 'view')
{
db.update(explorerContentTable).set({ visit: content.visit + 1 }).where(eq(explorerContentTable.path, path)).run();
}
if(query?.type === 'editing')
{
content.content = convertFromStorableLinks(content.content);
}
return Content.fromString(content as any, content.content);
}
console.log(content);
setResponseStatus(e, 404);
return;
}
catch(_e)
{
console.error(_e);
setResponseStatus(e, 500);
return;
}
});
export function convertFromStorableLinks(content: string): string
{
/*return content.replaceAll(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (e: string, a1?: string, a2?: string , a3?: string) => {
const parsed = parsePath(a1 ?? '%%%%----%%%%----%%%%');
const replacer = path.find(e => e.endsWith(parsed)) ?? parsed;
const value = `[[${replacer}${a2 ?? ''}${(!a3 && replacer !== parsed ? '|' + a1 : a3) ?? ''}]]`;
return value;
});*/
return content;
}

View File

@ -1,13 +1,11 @@
import useDatabase from '~/composables/useDatabase';
import { getTableColumns } from 'drizzle-orm';
import { explorerContentTable } from '~/db/schema';
import { projectFilesTable } from '~/db/schema';
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
const db = useDatabase();
const { content: _, ...columns } = getTableColumns(explorerContentTable);
const content = db.select(columns).from(explorerContentTable).all();
const content = db.select().from(projectFilesTable).all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;
@ -36,6 +34,5 @@ export default defineEventHandler(async (e) => {
return content.filter(e => !!e);
}
setResponseStatus(e, 404);
return;
return [];
});

View File

@ -0,0 +1,76 @@
import useDatabase from '~/composables/useDatabase';
import { hasPermissions } from "#shared/auth.util";
import { eq, sql } from "drizzle-orm";
import { projectFilesTable } from "~/db/schema";
import { Project } from "~/schemas/project";
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
if(!user || !hasPermissions(user.permissions, ['admin', 'editor']))
{
throw createError({ statusCode: 401, statusText: 'Unauthorized' });
}
const body = await readValidatedBody(e, Project.safeParse);
if(!body.success)
{
throw body.error;
}
const db = useDatabase(), items = body.data, blocked: string[] = [];
db.transaction((tx) => {
const data = tx.select({ id: projectFilesTable.id, timestamp: projectFilesTable.timestamp }).from(projectFilesTable).all();
const deletion = tx.delete(projectFilesTable).where(eq(projectFilesTable.id, sql.placeholder('id'))).prepare();
for(let i = 0; i < items.length; i++)
{
const item = items[i];
const index = data.findIndex(e => e.id === item.id);
if(index !== -1)
{
if(data[index].timestamp > new Date(item.timestamp))
{
blocked.push(item.id);
continue;
}
data.splice(index, 1);
}
tx.insert(projectFilesTable).values({
id: item.id,
path: item.path,
owner: user.id,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
}).onConflictDoUpdate({
set: {
id: item.id,
path: item.path,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
timestamp: new Date(),
},
target: projectFilesTable.id,
}).run();
}
for(let i = 0; i < data.length; i++)
{
deletion.run({ id: data[i].id });
}
});
return blocked;
});

View File

@ -1,11 +1,11 @@
import { eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { projectFilesTable } from '~/db/schema';
export default defineEventHandler(async (e) => {
const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
export default defineCachedEventHandler(async (e) => {
const id = getRouterParam(e, "id") ?? '';
if(!path)
if(!id)
{
setResponseStatus(e, 404);
return;
@ -14,13 +14,14 @@ export default defineEventHandler(async (e) => {
const db = useDatabase();
const content = db.select({
'path': explorerContentTable.path,
'owner': explorerContentTable.owner,
'title': explorerContentTable.title,
'type': explorerContentTable.type,
'navigable': explorerContentTable.navigable,
'private': explorerContentTable.private,
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
'id': projectFilesTable.id,
'path': projectFilesTable.path,
'owner': projectFilesTable.owner,
'title': projectFilesTable.title,
'type': projectFilesTable.type,
'navigable': projectFilesTable.navigable,
'private': projectFilesTable.private,
}).from(projectFilesTable).where(eq(projectFilesTable.id, sql.placeholder('id'))).prepare().get({ id });
if(content !== undefined)
{
@ -47,4 +48,4 @@ export default defineEventHandler(async (e) => {
setResponseStatus(e, 404);
return;
});
}, { getKey: (e) => getRouterParam(e, "id") ?? '', });

View File

@ -1,77 +0,0 @@
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import type { NavigationItem } from '~/schemas/navigation';
export type NavigationTreeItem = NavigationItem & { children?: NavigationTreeItem[] };
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
const db = useDatabase();
const content = db.select({
path: explorerContentTable.path,
type: explorerContentTable.type,
owner: explorerContentTable.owner,
title: explorerContentTable.title,
navigable: explorerContentTable.navigable,
private: explorerContentTable.private,
order: explorerContentTable.order,
}).from(explorerContentTable).all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;
});
if(content.length > 0)
{
const navigation: NavigationTreeItem[] = [];
for(const idx in content)
{
const item = content[idx];
if(!!item.private && (user?.id ?? -1) !== item.owner || !item.navigable)
{
delete content[idx];
continue;
}
const parent = item.path.includes('/') ? item.path.substring(0, item.path.lastIndexOf('/')) : undefined;
if(parent && !content.find(e => e && e.path === parent))
{
delete content[idx];
continue;
}
}
for(const item of content.filter(e => !!e))
{
addChild(navigation, item);
}
return navigation;
}
setResponseStatus(e, 404);
return;
});
function addChild(arr: NavigationTreeItem[], e: NavigationItem): void
{
const parent = arr.find(f => e.path.startsWith(f.path));
if(parent)
{
if(!parent.children)
parent.children = [];
addChild(parent.children, e);
}
else
{
arr.push({ ...e });
arr.sort((a, b) => {
if(a.order !== b.order)
return a.order - b.order;
return a.title.localeCompare(b.title);
});
}
}

View File

@ -1,80 +0,0 @@
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import type { NavigationItem } from '~/schemas/navigation';
import type { ProjectItem, Project } from '~/schemas/project';
import { hasPermissions } from '~/shared/auth.util';
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
if(!user || !hasPermissions(user.permissions, ['editor', 'admin']))
{
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const db = useDatabase();
const content = db.select({
path: explorerContentTable.path,
type: explorerContentTable.type,
owner: explorerContentTable.owner,
title: explorerContentTable.title,
navigable: explorerContentTable.navigable,
private: explorerContentTable.private,
order: explorerContentTable.order,
}).from(explorerContentTable).prepare().all();
content.sort((a, b) => {
return a.path.split('/').length - b.path.split('/').length;
});
if(content.length > 0)
{
const project: Project = {
items: [],
}
for(const item of content.filter(e => !!e))
{
addChild(project.items, item);
}
return project;
}
setResponseStatus(e, 404);
return;
});
function addChild(arr: ProjectItem[], e: NavigationItem): void
{
const parent = arr.find(f => e.path.startsWith(f.path));
if(parent)
{
if(!parent.children)
parent.children = [];
addChild(parent.children, e);
}
else
{
arr.push({
path: e.path,
parent: e.path.substring(0, e.path.lastIndexOf('/')),
name: e.path.substring(e.path.lastIndexOf('/') + 1),
title: e.title,
type: e.type,
navigable: e.navigable,
private: e.private,
order: e.order,
});
arr.sort((a, b) => {
if(a.order !== b.order)
return a.order - b.order;
return a.title.localeCompare(b.title);
});
}
}

View File

@ -1,106 +0,0 @@
import { hasPermissions } from "#shared/auth.util";
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { project, type ProjectItem } from '~/schemas/project';
import { parsePath } from "#shared/general.util";
import { eq, getTableColumns, sql } from "drizzle-orm";
import type { ExploreContent, TreeItem } from "~/types/content";
import type { TreeItemEditable } from "~/pages/explore/edit/index.vue";
export default defineEventHandler(async (e) => {
const { user } = await getUserSession(e);
if(!user || !hasPermissions(user.permissions, ['admin', 'editor']))
{
throw createError({ statusCode: 401, statusText: 'Unauthorized' });
}
const body = await readValidatedBody(e, project.safeParse);
if(!body.success)
{
throw body.error;
}
const items = buildOrder(body.data) as Array<TreeItemEditable & { match?: number }>;
const db = useDatabase();
const { ...cols } = getTableColumns(explorerContentTable);
const full = db.select(cols).from(explorerContentTable).all() as Record<string, any>[];
for(let i = full.length - 1; i >= 0; i--)
{
const item = items.find(e => (e.path === '' ? [e.parent, parsePath(e.name === '' ? e.title : e.name)].filter(e => !!e).join('/') : e.path) === full[i].path);
if(item)
{
item.match = i;
full[i].include = true;
}
}
db.transaction((tx) => {
for(let i = 0; i < items.length; i++)
{
const item = items[i];
const old = full[item.match!];
const path = [item.parent, parsePath(item.name === '' ? item.title : item.name)].filter(e => !!e).join('/');
tx.insert(explorerContentTable).values({
path: item.path || path,
owner: user.id,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
content: item.content ?? old?.content ?? null,
}).onConflictDoUpdate({
set: {
path: path,
title: item.title,
type: item.type,
navigable: item.navigable,
private: item.private,
order: item.order,
timestamp: new Date(),
content: item.content ?? old?.content ?? null,
},
target: explorerContentTable.path,
}).run();
if(item.path !== path && !old)
{
tx.update(explorerContentTable).set({ content: sql`replace(${explorerContentTable.content}, ${sql.placeholder('old')}, ${sql.placeholder('new')})` }).prepare().run({ 'old': item.path, 'new': path });
}
}
for(let i = 0; i < full.length; i++)
{
if(full[i].include !== true)
tx.delete(explorerContentTable).where(eq(explorerContentTable.path, full[i].path)).run();
}
});
return items.map(e => ({
path: e.path,
owner: e.owner,
title: e.title,
type: e.type,
content: e.content,
navigable: e.navigable,
private: e.private,
order: e.order,
visit: e.visit,
timestamp: e.timestamp,
})) as ExploreContent[];
});
function buildOrder(items: ProjectItem[]): ProjectItem[]
{
items.forEach((e, i) => {
e.order = i;
if(e.children) e.children = buildOrder(e.children);
});
return items.flatMap(e => [e, ...(e.children ?? [])]);
}

View File

@ -1,9 +1,9 @@
import useDatabase from "~/composables/useDatabase";
import { extname, basename } from 'node:path';
import type { FileType } from '~/types/content';
import type { CanvasColor, CanvasContent } from "~/types/canvas";
import { explorerContentTable } from "~/db/schema";
import { convertToStorableLinks } from "../api/file.post";
import type { FileType, ProjectContent } from "#shared/content.util";
import { getID, ID_SIZE } from "#shared/general.util";
import { projectContentTable, projectFilesTable } from "~/db/schema";
const typeMapping: Record<string, FileType> = {
".md": "markdown",
@ -17,6 +17,7 @@ export default defineTask({
},
async run(event) {
try {
//@ts-ignore
const tree = await $fetch('https://git.peaceultime.com/api/v1/repos/peaceultime/system-aspect/git/trees/master', {
method: 'get',
headers: {
@ -28,21 +29,23 @@ export default defineTask({
}
}) as { tree: any[] } & Record<string, any>;
const files: typeof explorerContentTable.$inferInsert[] = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e, i) => {
const files: ProjectContent[] = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e, i) => {
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 {
id: getID(ID_SIZE),
path: path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
order: i,
title: order && order[2] ? order[2] : title,
type: 'folder',
content: null,
owner: '1',
owner: 1,
navigable: true,
private: e.path.startsWith('98.Privé'),
timestamp: new Date(),
}
}
@ -53,28 +56,26 @@ export default defineTask({
const content = (await $fetch(`https://git.peaceultime.com/api/v1/repos/peaceultime/system-aspect/raw/${encodeURIComponent(e.path)}`));
return {
id: getID(ID_SIZE),
path: (extension === '.md' ? path.replace(extension, '') : path).toLowerCase().replaceAll(" ", "-").normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
order: i,
title: order && order[2] ? order[2] : title,
type: (typeMapping[extension] ?? 'file'),
content: reshapeContent(content as string, typeMapping[extension] ?? 'File'),
owner: '1',
owner: 1,
navigable: true,
private: e.path.startsWith('98.Privé')
private: e.path.startsWith('98.Privé'),
timestamp: new Date(),
}
}));
const pathList = files.map(e => e.path);
files.forEach(e => {
if(e.type !== 'folder' && e.content)
{
e.content = Buffer.from(convertToStorableLinks(e.content.toString('utf-8'), files.map(e => e.path)), 'utf-8');
}
})
const db = useDatabase();
db.delete(explorerContentTable).run();
db.insert(explorerContentTable).values(files).run();
db.transaction(tx => {
db.delete(projectFilesTable).run();
db.insert(projectFilesTable).values(files.map(e => {const { content, ...rest } = e; return rest; })).run();
db.delete(projectContentTable).run();
db.insert(projectContentTable).values(files.map(e => ({ content: e.content ? Buffer.from(e.content as string) : null, id: e.id }))).run();
});
return { result: true };
}
@ -99,8 +100,8 @@ function reshapeContent(content: string, type: FileType): string | null
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);
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':

View File

@ -1,4 +1,4 @@
import useDatabase from "~/composables/useDatabase";
/*import useDatabase from "~/composables/useDatabase";
import type { FileType } from '~/types/content';
import { explorerContentTable } from "~/db/schema";
import { eq, ne } from "drizzle-orm";
@ -27,4 +27,4 @@ export default defineTask({
return { result: false, error: e };
}
},
})
})*/

View File

@ -1,11 +1,11 @@
import { safeDestr as parse } from 'destr';
import { Canvas } from "#shared/canvas.util";
import render from "#shared/markdown.util";
import { contextmenu, popper } from "#shared/floating.util";
import { confirm, contextmenu, popper } from "#shared/floating.util";
import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util";
import prose, { h1, h2, loading } from "#shared/proses";
import { parsePath } from '#shared/general.util';
import { Tree, TreeDOM, type Recursive } from '#shared/tree';
import { getID, ID_SIZE, parsePath } from '#shared/general.util';
import { TreeDOM, type Recursive } from '#shared/tree';
import { History } from '#shared/history.util';
import { MarkdownEditor } from '#shared/editor.util';
import type { CanvasContent } from '~/types/canvas';
@ -13,6 +13,7 @@ import type { CanvasContent } from '~/types/canvas';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import type { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';
export type FileType = keyof ContentMap;
@ -26,6 +27,7 @@ export interface ContentMap
}
export interface Overview<T extends FileType>
{
id: string;
path: string;
owner: number;
title: string;
@ -33,10 +35,9 @@ export interface Overview<T extends FileType>
navigable: boolean;
private: boolean;
order: number;
visit: number;
type: T;
}
export type ExploreContent<T extends FileType = FileType> = Overview<T> & { content: ContentMap[T] };
export type ProjectContent<T extends FileType = FileType> = Overview<T> & { content: ContentMap[T] };
class AsyncQueue
{
@ -48,6 +49,7 @@ class AsyncQueue
finished: boolean = true;
private res: (value: void | PromiseLike<void>) => void = () => {};
private rej: (value: void | PromiseLike<void>) => void = () => {};
constructor(size: number = 8)
{
@ -63,6 +65,7 @@ class AsyncQueue
this.promise = new Promise((res, rej) => {
this.res = res;
this.rej = rej;
});
}
@ -79,7 +82,7 @@ class AsyncQueue
this.count++;
const fn = this._queue.shift()!;
fn().finally(() => {
fn().catch(e => this.rej(e)).then(() => {
this.count--;
this.refresh();
});
@ -100,7 +103,7 @@ export const DEFAULT_CONTENT: Record<FileType, ContentMap[FileType]> = {
file: '',
folder: null,
};
export type LocalContent<T extends FileType = FileType> = ExploreContent<T> & {
export type LocalContent<T extends FileType = FileType> = ProjectContent<T> & {
localEdit?: boolean;
error?: boolean;
};
@ -111,16 +114,15 @@ export class Content
private static root: FileSystemDirectoryHandle;
private static _overview: Omit<LocalContent, 'content'>[];
private static dlQueue = new AsyncQueue();
private static writeQueue = new AsyncQueue(1);
private static _overview: Record<string, Omit<LocalContent, 'content'>>;
private static queue = new AsyncQueue();
static init(): Promise<boolean>
{
if(Content._ready)
return Promise.resolve(true);
Content.initPromise = new Promise(async (res, rej) => {
Content.initPromise = new Promise(async (res) => {
try
{
if(!('storage' in navigator))
@ -131,11 +133,11 @@ export class Content
const overview = await Content.read('overview', { create: true });
try
{
Content._overview = parse<Omit<LocalContent, 'content'>[]>(overview);
Content._overview = parse<Record<string, Omit<LocalContent, 'content'>>>(overview);
}
catch(e)
{
Content._overview = [];
Content._overview = {};
await Content.pull();
}
@ -151,51 +153,61 @@ export class Content
return Content.initPromise;
}
static overview(path: string): Omit<LocalContent, 'content'> | undefined
static async get(id: string, content: boolean): Promise<LocalContent | undefined>
{
return Content._overview.find(e => getPath(e) === path);
}
static async content(path: string): Promise<LocalContent | undefined>
{
const overview = Content._overview.find(e => getPath(e) === path);
const overview = Content._overview[id];
return overview ? { ...overview, content: Content.fromString(overview, await Content.read(encodeURIComponent(path)) ?? '') } as LocalContent : undefined;
return overview ? { ...overview, content: content ? Content.fromString(overview, await Content.read(id) ?? '') : undefined } as LocalContent : undefined;
}
static update(item: Recursive<LocalContent>)
static async set(id: string, overview?: Omit<LocalContent, 'content'> | Recursive<Omit<LocalContent, 'content'>>)
{
const index = Content._overview.findIndex(e => e.path === getPath(item));
if(index !== -1)
Content._overview[index] = item;
if(overview === undefined)
{
delete Content._overview[id];
}
else
{
const {
id: _id,
path: _path,
owner: _owner,
title: _title,
timestamp: _timestamp,
navigable: _navigable,
private: _private,
order: _order,
type: _type,
...rest
} = overview as Recursive<LocalContent>;
Content._overview[id] = {
id: _id,
path: _path,
owner: _owner,
title: _title,
timestamp: _timestamp,
navigable: _navigable,
private: _private,
order: _order,
type: _type,
};
}
}
static async save(content?: ProjectContent)
{
const overviewAsString = JSON.stringify(Content._overview)
Content.queue.queue(() => Content.write("overview", overviewAsString));
const overview = JSON.stringify(Content._overview, (k, v) => ['parent', 'children', 'content'].includes(k) ? undefined : v);
return Content.writeQueue.queue(() => Content.write('overview', overview));
}
static rename(from: string, to: string)
{
const index = Content._overview.findIndex(e => getPath(e) === from);
if(index !== -1)
Content._overview[index].path = to;
if(content && content.content)
{
const contentAsString = Content.toString(content);
Content.queue.queue(() => Content.write(content.id, contentAsString));
}
return Content.writeQueue.queue(async () => {
const content = await Content.read(encodeURIComponent(from));
if(content !== undefined)
{
await Content.write(encodeURIComponent(to), content, { create: true });
await Content.remove(encodeURIComponent(from));
}
});
return Content.queue.promise;
}
static save(content?: Recursive<LocalContent>)
static async pull()
{
if(!content)
return;
const string = Content.toString(content), path = getPath(content);
return Content.writeQueue.queue(() => Content.write(encodeURIComponent(path), string, { create: true }));
}
private static async pull()
{
const overview = (await useRequestFetch()('/api/file/overview', { cache: 'no-cache' })) as ExploreContent<FileType>[] | undefined;
const overview = (await useRequestFetch()('/api/file/overview', { cache: 'no-cache' })) as ProjectContent<FileType>[] | undefined;
if(!overview)
{
@ -206,44 +218,50 @@ export class Content
for(const file of overview)
{
let index = Content._overview.findIndex(e => getPath(e) === file.path), _overview = (index === -1 ? undefined : Content._overview[index]);
if(!_overview || _overview.localEdit)
const _overview = Content._overview[file.id];
if(_overview && _overview.localEdit)
{
const encoded = encodeURIComponent(file.path);
if(!_overview)
{
index = Content._overview.push(file) - 1;
}
else
Content._overview[index] = file;
_overview = file;
//TODO: Ask what to do about this file.
}
else
{
Content._overview[file.id] = file;
if(file.type === 'folder')
continue;
Content.dlQueue.queue(() => {
return useRequestFetch()(`/api/file/content/${encoded}`, { cache: 'no-cache' }).then(async (content: ContentMap[FileType] | undefined) => {
Content.queue.queue(() => {
return useRequestFetch()(`/api/file/content/${file.id}`, { cache: 'no-cache' }).then(async (content: ContentMap[FileType] | undefined) => {
if(content)
await Content.write(encoded, Content.toString({ ...file, content }), { create: true });
Content.queue.queue(() => Content.write(file.id, Content.toString({ ...file, content }), { create: true }));
else
Content._overview[index].error = true;
Content._overview[file.id].error = true;
}).catch(e => {
Content._overview[index].error = true;
})
Content._overview[file.id].error = true;
});
});
}
}
Content.dlQueue.queue(() => {
return Content.queue.queue(() => {
return Content.write('overview', JSON.stringify(Content._overview), { create: true });
});
await Content.dlQueue.promise;
}
private static async push()
static async push()
{
const blocked = (await useRequestFetch()('/api/file/overview', { method: 'POST', body: Object.values(Content._overview), cache: 'no-cache' }));
for(const [id, value] of Object.entries(Content._overview).filter(e => !blocked.includes(e[0])))
{
if(value.type === 'folder')
continue;
Content.queue.queue(() => Content.read(id).then(e => {
if(e) Content.queue.queue(() => useRequestFetch()(`/api/file/content/${id}`, { method: 'POST', body: e, cache: 'no-cache' }));
}));
}
return Content.queue.promise;
}
//Maybe store the file handles ? Is it safe to keep them ?
private static async read(path: string, options?: FileSystemGetFileOptions): Promise<string | undefined>
@ -283,24 +301,18 @@ export class Content
}
console.timeEnd(`Writing ${size} bytes to '${path}'`);
}
private static async remove(path: string): Promise<void>
{
console.time(`Removing '${path}'`);
await Content.root.removeEntry(path)
console.timeEnd(`Removing '${path}'`);
}
static get estimate(): Promise<StorageEstimate>
{
return Content._ready ? navigator.storage.estimate() : Promise.reject();
}
static toString<T extends FileType>(content: ExploreContent<T>): string
static toString<T extends FileType>(content: ProjectContent<T>): string
{
return handlers[content.type].toString(content.content);
}
static fromString<T extends FileType>(overview: Omit<ExploreContent<T>, 'content'>, content: string): ContentMap[T]
static fromString<T extends FileType>(overview: Omit<ProjectContent<T>, 'content'>, content: string): ContentMap[T]
{
return handlers[overview.type].fromString(content);
}
@ -362,7 +374,7 @@ export class Content
for(const element of Object.values(Content._overview))
{
addChild(arr, element);
addChild(arr, {...element});
}
return arr;
@ -411,9 +423,9 @@ const handlers: { [K in FileType]: ContentTypeHandler<K> } = {
else
{
element = loading("large");
Content.content(content.path).then(e => {
Content.get(content.id, true).then(e => {
if(!e)
return element.parentElement?.replaceChild(dom('div', { class: '', text: '' }), element);
return element.parentElement?.replaceChild(dom('div', { class: '', text: 'Une erreur est survenue.' }), element);
MarkdownEditor.singleton.content = content.content = (e as typeof content).content;
element.parentElement?.replaceChild(MarkdownEditor.singleton.dom, element);
@ -452,7 +464,7 @@ export const iconByType: Record<FileType, string> = {
'file': 'radix-icons:file',
'markdown': 'radix-icons:file-text',
'map': 'lucide:map',
}
};
export class Editor
{
@ -472,7 +484,7 @@ export class Editor
this.history.register('overview', {
move: {
undo: (action) => {
this.tree.tree.remove(getPath(action.element));
this.tree.tree.remove(action.element.id);
action.element.parent = action.from.parent;
action.element.order = action.from.order;
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
@ -483,11 +495,10 @@ export class Editor
this.dragndrop(action.element, depth, action.element.parent);
this.tree.update();
Content.rename(action.element.path, path);
action.element.path = path;
},
redo: (action) => {
this.tree.tree.remove(getPath(action.element));
this.tree.tree.remove(action.element.id);
action.element.parent = action.to.parent;
action.element.order = action.to.order;
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
@ -499,16 +510,16 @@ export class Editor
this.dragndrop(action.element, depth, action.element.parent);
this.tree.update();
Content.rename(action.element.path, path);
action.element.path = path;
},
},
add: {
undo: (action) => {
this.tree.tree.remove(getPath(action.element));
this.tree.tree.remove(action.element.id);
if(this.selected === action.element) this.select();
action.element.cleanup();
action.element.remove();
action.element.element?.remove();
},
redo: (action) => {
if(!action.element)
@ -527,10 +538,10 @@ export class Editor
this.tree.update();
},
redo: (action) => {
this.tree.tree.remove(getPath(action.element));
this.tree.tree.remove(action.element.id);
if(this.selected === action.element) this.select();
action.element.cleanup();
action.element.remove();
action.element.element?.remove();
},
},
rename: {
@ -542,7 +553,6 @@ export class Editor
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
action.element?.cleanup();
this.dragndrop(action.element, depth, action.element.parent);
Content.rename(action.element.path, path);
action.element.path = path;
},
redo: (action) => {
@ -553,7 +563,6 @@ export class Editor
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
action.element?.cleanup();
this.dragndrop(action.element, depth, action.element.parent);
Content.rename(action.element.path, path);
action.element.path = path;
},
},
@ -577,7 +586,7 @@ export class Editor
action.element.element!.children[0].children[3].children[0].replaceWith(icon(action.element.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !action.element.private }] }));
},
},
}, action => Content.update(action.element));
}, () => { this.tree.tree.each(e => Content.set(e.id, e)); Content.save(); });
this.tree = new TreeDOM((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 cursor-pointer font-medium'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent)} }, [
@ -601,20 +610,20 @@ export class Editor
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' })]);
}
private contextmenu(e: MouseEvent, item: LocalContent)
private contextmenu(e: MouseEvent, item: Recursive<LocalContent>)
{
e.preventDefault();
const close = contextmenu(e.clientX, e.clientY, { placement: 'right-start', offset: 8, content: [
const close = contextmenu(e.clientX, e.clientY, [
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.add("markdown", item); close() }} }, [icon('radix-icons:plus'), text('Ajouter')]),
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.rename(item); close() }} }, [icon('radix-icons:input'), text('Renommer')]),
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-red dark:text-dark-red', listeners: { click: (e) => { this.remove(item); close() }} }, [icon('radix-icons:trash'), text('Supprimer')]),
] });
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-red dark:text-dark-red', listeners: { click: (e) => { close(); confirm(`Confirmer la suppression de ${item.title}${item.children ? ' et de ses enfants' : ''} ?`).then(e => { if(e) this.remove(item)}) }} }, [icon('radix-icons:trash'), text('Supprimer')]),
], { placement: 'right-start', offset: 8 });
}
private add(type: FileType, nextTo: Recursive<LocalContent>)
{
const count = Object.values(Content.files).filter(e => e.title.match(/^Nouveau( \(\d+\))?$/)).length;
const item: Recursive<Omit<LocalContent, 'path' | 'content'> & { element?: HTMLElement }> = { navigable: true, private: false, owner: 0, order: nextTo.order + 1, timestamp: new Date(), title: count === 0 ? 'Nouveau' : `Nouveau (${count})`, type: type, visit: 0, parent: nextTo.parent };
const item: Recursive<Omit<LocalContent, 'path' | 'content'> & { element?: HTMLElement }> = { id: getID(ID_SIZE), navigable: true, private: false, owner: 0, order: nextTo.order + 1, timestamp: new Date(), title: count === 0 ? 'Nouveau' : `Nouveau (${count})`, type: type, parent: nextTo.parent };
this.history.add('overview', 'add', [{ element: item, from: undefined, to: nextTo.order + 1 }]);
}
private remove(item: LocalContent & { element?: HTMLElement })
@ -670,6 +679,8 @@ export class Editor
if (instruction !== null)
this.updateTree(instruction, location.initial.dropTargets[0].data.id as string, target.data.id as string);
},
}), autoScrollForElements({
element: this.tree.container,
}));
}
private dragndrop(item: Omit<LocalContent & { element?: HTMLElement, cleanup?: () => void }, "content">, depth: number, parent?: Omit<LocalContent & { element?: HTMLElement }, "content">): CleanupFn
@ -697,7 +708,7 @@ export class Editor
dropTargetForElements({
element,
getData: ({ input }) => {
const data = { id: getPath(item) };
const data = { id: item.id };
return attachInstruction(data, {
input,

View File

@ -7,7 +7,7 @@ export type Class = string | Array<Class> | Record<string, boolean> | undefined;
type Listener<K extends keyof HTMLElementEventMap> = | ((ev: HTMLElementEventMap[K]) => any) | {
options?: boolean | AddEventListenerOptions;
listener: (ev: HTMLElementEventMap[K]) => any;
}
} | undefined;
export interface NodeProperties
{
@ -43,7 +43,7 @@ export function dom<K extends keyof HTMLElementTagNameMap>(tag: K, properties?:
const key = k as keyof HTMLElementEventMap, value = v as Listener<typeof key>;
if(typeof value === 'function')
element.addEventListener(key, value);
else
else if(value)
element.addEventListener(key, value.listener, value.options);
}
}

View File

@ -8,6 +8,7 @@ import { lintKeymap } from '@codemirror/lint';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { IterMode, Tree } from '@lezer/common';
import { tags } from '@lezer/highlight';
import { dom } from './dom.util';
const External = Annotation.define<boolean>();
const Hidden = Decoration.mark({ class: 'hidden' });
const Bullet = Decoration.mark({ class: '*:hidden before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4' });
@ -199,4 +200,52 @@ export class MarkdownEditor
MarkdownEditor._singleton = new MarkdownEditor();
return MarkdownEditor._singleton;
}
}
export class FramedEditor
{
editor: MarkdownEditor;
dom: HTMLIFrameElement;
private static _singleton: FramedEditor;
static get singleton()
{
if(!FramedEditor._singleton)
FramedEditor._singleton = new FramedEditor();
return FramedEditor._singleton;
}
private constructor()
{
this.editor = MarkdownEditor.singleton;
this.dom = dom('iframe');
this.dom.addEventListener('load', () => {
if(!this.dom.contentDocument)
return;
this.dom.contentDocument.documentElement.setAttribute('class', document.documentElement.getAttribute('class') ?? '');
this.dom.contentDocument.documentElement.setAttribute('style', document.documentElement.getAttribute('style') ?? '');
const base = this.dom.contentDocument.head.appendChild(this.dom.contentDocument.createElement('base'));
base.setAttribute('href', window.location.href);
for(let element of document.getElementsByTagName('link'))
{
if(element.getAttribute('rel') === 'stylesheet')
this.dom.contentDocument.head.appendChild(element.cloneNode(true));
}
for(let element of document.getElementsByTagName('style'))
{
this.dom.contentDocument.head.appendChild(element.cloneNode(true));
}
this.dom.contentDocument.body.setAttribute('class', document.body.getAttribute('class') ?? '');
this.dom.contentDocument.body.setAttribute('style', document.body.getAttribute('style') ?? '');
this.dom.contentDocument.body.appendChild(this.editor.dom);
this.editor.focus();
})
}
}

View File

@ -1,22 +1,33 @@
import * as FloatingUI from "@floating-ui/dom";
import { cancelPropagation, dom, svg, type Class, type NodeChildren } from "./dom.util";
import { cancelPropagation, dom, svg, text, type Class, type NodeChildren } from "./dom.util";
import { button } from "./proses";
export interface CommonProperties
export interface ContextProperties
{
placement?: FloatingUI.Placement;
offset?: number;
arrow?: boolean;
class?: Class;
}
export interface PopperProperties
{
placement?: FloatingUI.Placement;
offset?: number;
arrow?: boolean;
class?: Class;
content?: NodeChildren;
}
export interface PopperProperties extends CommonProperties
{
delay?: number;
onShow?: (element: HTMLDivElement) => boolean | void;
onHide?: (element: HTMLDivElement) => boolean | void;
}
export interface ModalProperties
{
priority?: boolean;
closeWhenOutside?: boolean;
}
let teleport: HTMLDivElement;
export function init()
{
@ -141,7 +152,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
return container;
}
export function contextmenu(x: number, y: number, properties?: CommonProperties): () => void
export function contextmenu(x: number, y: number, content: NodeChildren, properties?: ContextProperties): () => void
{
const virtual = {
getBoundingClientRect() {
@ -159,7 +170,7 @@ export function contextmenu(x: number, y: number, properties?: CommonProperties)
};
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]);
const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class] }, [...(properties?.content ?? [])]);
const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class] }, content);
function update()
{
@ -236,7 +247,26 @@ export function contextmenu(x: number, y: number, properties?: CommonProperties)
return close;
}
//TODO
export function modal()
export function modal(content: NodeChildren, properties?: ModalProperties)
{
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (() => _modal.remove()) : undefined } });
const _closer = properties?.priority ? undefined : dom('span', { class: 'absolute top-4 right-4', text: '×', listeners: { click: () => _modal.remove() } });
const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, dom('div', { class: 'max-h-[85vh] max-w-[450px] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 text-light-100 dark:text-dark-100 z-10 relative' }, content)])
teleport.appendChild(_modal);
return {
close: () => _modal.remove(),
}
}
export function confirm(title: string): Promise<boolean>
{
return new Promise(res => {
const mod = modal([ dom('div', { class: 'flex flex-col justify-start gap-4' }, [ dom('div', { class: 'text-xl' }, [ text(title) ]), dom('div', { class: 'flex flex-row gap-2' }, [ button(text("Non"), () => (mod.close(), res(false)), 'h-[35px] px-[15px]'), button(text("Oui"), () => (mod.close(), res(true)), 'h-[35px] px-[15px] !border-light-red dark:!border-dark-red text-light:red dark:text-dark-red') ]) ]) ], {
priority: true,
closeWhenOutside: false,
});
})
}

View File

@ -1,7 +1,15 @@
export const ID_SIZE = 32;
export function unifySlug(slug: string | string[]): string
{
return (Array.isArray(slug) ? slug.join('/') : slug);
}
export function getID(length: number)
{
for (var id = [], i = 0; i < length; i++)
id.push((16 * Math.random() | 0).toString(16));
return id.join("");
}
export function parsePath(path: string): string
{
return path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replaceAll(/[\u0300-\u036f]/g, "").replaceAll('(', '').replaceAll(')', '');

View File

@ -18,7 +18,7 @@ interface HistoryAction
export class History
{
private handlers: Record<string, { handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => void }>;
private handlers: Record<string, { handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => any }>;
private history: HistoryEvent[];
private position: number;
@ -83,7 +83,7 @@ export class History
this.handlers[source] && this.handlers[source].any && this.handlers[source].any(e);
});
}
register(source: string, handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => void)
register(source: string, handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => any)
{
this.handlers[source] = { handlers, any };
}

View File

@ -252,4 +252,10 @@ export function link(properties?: NodeProperties & { active?: Class }, link?: Ro
export function loading(size: 'small' | 'normal' | 'large' = 'normal'): HTMLElement
{
return dom('span', { class: ["after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin", {'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}] })
}
export function button(content: Node, onClick?: () => void, cls?: Class)
{
return dom('button', { class: [`text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50`, cls], listeners: { click: onClick } }, [ content ]);
}

View File

@ -1,4 +1,4 @@
import { Content, getPath, type LocalContent } from "./content.util";
import { Content, type LocalContent } from "./content.util";
import { dom } from "./dom.util";
import { clamp } from "./general.util";
@ -17,9 +17,9 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
this._flatten = this.accumulate(e => e);
}
remove(path: string)
remove(id: string)
{
const recursive = (data?: Recursive<T>[], parent?: T) => data?.filter(e => getPath(e) !== path)?.map((e, i) => {
const recursive = (data?: Recursive<T>[], parent?: T) => data?.filter(e => e.id !== id)?.map((e, i) => {
e.order = i;
e.children = recursive(e.children as T[], e);
return e;
@ -30,9 +30,9 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
}
insertAt(item: Recursive<T>, pos: number)
{
const parent = item.parent ? getPath(item.parent) : undefined;
const parent = item.parent ? item.parent.id : undefined;
const recursive = (data?: Recursive<T>[]) => data?.flatMap(e => {
if(getPath(e) === parent)
if(e.id === parent)
{
e.children = e.children ?? [];
e.children.splice(clamp(pos, 0, e.children.length), 0, item);
@ -53,7 +53,7 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
this._flatten = this.accumulate(e => e);
}
find(path: string): T | undefined
find(id: string): T | undefined
{
const recursive = (data?: Recursive<T>[]): T | undefined => {
if(!data)
@ -61,7 +61,7 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
for(const e of data)
{
if(getPath(e) === path)
if(e.id === id)
return e;
const result = recursive(e.children as T[]);
@ -94,9 +94,9 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
return recursive(this._data);
}
subset(path: string): Tree<T> | undefined
subset(id: string): Tree<T> | undefined
{
const subset = this.find(path);
const subset = this.find(id);
return subset ? new Tree([subset]) : undefined;
}
each(callback: (item: T, depth: number, parent?: T) => void)

5
types/auth.d.ts vendored
View File

@ -31,9 +31,8 @@ export interface UserRawData {
}
export interface UserExtendedData {
signin: string;
lastTimestamp: string;
logCount: number;
signin: Date;
lastTimestamp: Date;
}
export type Permissions = { permissions: string[] };