(admin)(blog) very simple post editor

This commit is contained in:
Valentyne Stigloher 2025-01-01 23:26:34 +01:00
parent 15c20ea07c
commit 64735cac1b
9 changed files with 129 additions and 63 deletions

View File

@ -10,7 +10,7 @@ install:
-cp -n .env.dist .env
if [ ! -d "${KEYS_DIR}" ]; then mkdir -p ${KEYS_DIR}; openssl genrsa -out ${KEYS_DIR}/private.pem 2048; openssl rsa -in ${KEYS_DIR}/private.pem -outform PEM -pubout -out ${KEYS_DIR}/public.pem; fi
mkdir -p moderation
touch moderation/sus.txt moderation/rules-users.md moderation/rules-terminology.md moderation/rules-sources.md moderation/timesheets.md moderation/expenses.md
touch moderation/sus.txt moderation/rules-users.md moderation/rules-terminology.md moderation/rules-sources.md moderation/timesheets.md moderation/expenses.md moderation/blog.md
pnpm install
pnpm run-file server/migrate.ts

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import Columnist from 'avris-columnist';
import type { Post } from '~/server/blog.ts';
import type { Post } from '~/src/blog/metadata.ts';
const props = defineProps<{
posts: string[] | Post[];

View File

@ -2,7 +2,7 @@
import type { RouteLocationRaw } from 'vue-router';
import useConfig from '~/composables/useConfig.ts';
import type { Post } from '~/server/blog.ts';
import type { Post } from '~/src/blog/metadata.ts';
const props = defineProps<{
post: Post;

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import { useDebounce, useLocalStorage } from '@vueuse/core';
import marked from 'marked';
import { extractMetadata, type Post } from '~/src/blog/metadata.ts';
import parseMarkdown, { type MarkdownInfo } from '~/src/parseMarkdown.ts';
const content = useLocalStorage(
'admin/blog/editor',
'# title\n<small>YYYY-MM-DD | author</small>\n\n![alt text for image](/img-local/blog/slug.png)\n\ncontent',
{ initOnMounted: true },
);
const debouncedContent = useDebounce(content, 500, { maxWait: 5_000 });
const post = ref<Post | undefined>();
const parsed = ref<MarkdownInfo | undefined>();
const { $translator: translator } = useNuxtApp();
const config = useConfig();
watch(debouncedContent, async () => {
const metadata = extractMetadata(config, debouncedContent.value);
post.value = metadata ? { slug: 'slug', ...metadata } : undefined;
const markdown = marked(debouncedContent.value);
parsed.value = await parseMarkdown(markdown, translator);
});
const moderationAsyncData = await useFetch('/api/admin/moderation', { pick: ['blog'] });
</script>
<template>
<Page>
<NotFound v-if="!$isGranted('panel')" />
<template v-else>
<p>
<nuxt-link to="/admin">
<Icon v="user-cog" />
<T>admin.header</T>
</nuxt-link>
</p>
<h2>
<Icon v="pen-nib" />
Blog editor
</h2>
<div v-html="moderationAsyncData.data.value?.blog"></div>
<textarea v-model="content" class="form-control"></textarea>
<template v-if="post">
<hr>
<BlogEntry class="col-12 col-sm-6 col-md-4" :post details />
</template>
<hr>
<Spelling v-if="parsed?.content" :text="parsed.content" />
</template>
</Page>
</template>

View File

@ -263,6 +263,14 @@ const impersonate = async (email: string) => {
{ name: 'awaiting', count: stats.locales[locale].names.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="config.links.enabled && config.links.blog"
:base-url="url"
icon="pen-nib"
header="Blog"
>
<nuxt-link :to="{ name: 'admin-blog-editor' }" class="btn btn-sm btn-secondary text-white">Editor</nuxt-link>
</AdminDashboardCard>
<AdminDashboardCard
v-if="stats && $isGranted('translations', locale) && (stats.locales[locale].translations.missing > 0 || stats.locales[locale].translations.awaitingApproval > 0) || stats && $isGranted('code', locale) && stats.locales[locale].translations.awaitingMerge > 0"
v-show="!filterAttention || stats.locales[locale].translations.missing || stats.locales[locale].translations.awaitingApproval || stats?.locales[locale].translations.awaitingMerge"

View File

@ -26,5 +26,6 @@ export default defineEventHandler(async (event) => {
rulesSources: md.render(await fs.readFile(`${dir}/rules-sources.md`, 'utf-8')),
timesheets: md.render(await fs.readFile(`${dir}/timesheets.md`, 'utf-8')),
expenses: md.render(await fs.readFile(`${dir}/expenses.md`, 'utf-8')),
blog: md.render(await fs.readFile(`${dir}/blog.md`, 'utf-8')),
};
});

View File

@ -3,68 +3,10 @@ import fs from 'node:fs/promises';
import { defineCachedFunction } from 'nitropack/runtime';
import SQL from 'sql-template-strings';
import type { Config } from '~/locale/config.ts';
import type { Database } from '~/server/db.ts';
import type { UserRow } from '~/server/express/user.ts';
import { rootDir } from '~/server/paths.ts';
export interface PostMetadata {
title: string;
date: string;
authors: string[];
hero: { src: string; alt: string; class: string } | undefined;
}
export interface Post extends PostMetadata {
slug: string;
}
export const extractMetadata = (config: Config, content: string): PostMetadata | undefined => {
const lines = content.split('\n').filter((l) => !!l);
const title = lines[0].match(/^# (.*)$/)?.[1];
const secondLineMatch = lines[1].match(/^<small>(\d\d\d\d-\d\d-\d\d) \| ([^|]*).*<\/small>$/);
const date = secondLineMatch?.[1];
const authors = secondLineMatch?.[2].split(',').map((a) => {
a = a.trim();
const teamName = config.contact.team?.route;
if (teamName && a.startsWith(teamName)) {
return teamName;
}
const m = a.match(/^\[([^\]]+)]/);
if (m) {
return m[1];
}
return a;
}) ?? [];
let hero = undefined;
const classHeroImages = lines
.map((x) => x.match(/<img src="([^"]+)" class="hero([^"]*)" alt="([^"]*)"/))
.filter((x) => !!x);
if (classHeroImages.length) {
hero = {
src: classHeroImages[0][1],
alt: classHeroImages[0][3],
class: classHeroImages[0][2].replace('d-none', ''),
};
} else {
const images = lines.map((x) => x.match(/^!\[([^\]]*)]\(([^)]+)\)$/)).filter((x) => !!x);
if (images.length) {
hero = {
src: images[0][2],
alt: images[0][1],
class: '',
};
}
}
if (title === undefined || date === undefined) {
return undefined;
}
return { title, date, authors, hero };
};
import { extractMetadata, type Post } from '~/src/blog/metadata.ts';
export const getPosts = defineCachedFunction(async (): Promise<Post[]> => {
const dir = `${rootDir}/data/blog`;

59
src/blog/metadata.ts Normal file
View File

@ -0,0 +1,59 @@
import type { Config } from '~/locale/config.ts';
export interface PostMetadata {
title: string;
date: string;
authors: string[];
hero: { src: string; alt: string; class: string } | undefined;
}
export interface Post extends PostMetadata {
slug: string;
}
export const extractMetadata = (config: Config, content: string): PostMetadata | undefined => {
const lines = content.split('\n').filter((l) => !!l);
const title = lines[0]?.match(/^# (.*)$/)?.[1];
const secondLineMatch = lines[1]?.match(/^<small>(\d\d\d\d-\d\d-\d\d) \| ([^|]*).*<\/small>$/);
const date = secondLineMatch?.[1];
const authors = secondLineMatch?.[2].split(',').map((a) => {
a = a.trim();
const teamName = config.contact.team?.route;
if (teamName && a.startsWith(teamName)) {
return teamName;
}
const m = a.match(/^\[([^\]]+)]/);
if (m) {
return m[1];
}
return a;
}) ?? [];
let hero = undefined;
const classHeroImages = lines
.map((x) => x.match(/<img src="([^"]+)" class="hero([^"]*)" alt="([^"]*)"/))
.filter((x) => !!x);
if (classHeroImages.length) {
hero = {
src: classHeroImages[0][1],
alt: classHeroImages[0][3],
class: classHeroImages[0][2].replace('d-none', ''),
};
} else {
const images = lines.map((x) => x.match(/^!\[([^\]]*)]\(([^)]+)\)$/)).filter((x) => !!x);
if (images.length) {
hero = {
src: images[0][2],
alt: images[0][1],
class: '',
};
}
}
if (title === undefined || date === undefined) {
return undefined;
}
return { title, date, authors, hero };
};

View File

@ -13,7 +13,7 @@ import { loadSumlFromBase } from '../../server/loader.ts';
import parseMarkdown from '../../src/parseMarkdown.ts';
import { Translator } from '../../src/translator.ts';
import { extractMetadata } from '~/server/blog.ts';
import { extractMetadata } from '~/src/blog/metadata.ts';
const validator = new HtmlValidate({
extends: [