mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-22 12:03:25 -04:00
(admin)(blog) very simple post editor
This commit is contained in:
parent
15c20ea07c
commit
64735cac1b
2
Makefile
2
Makefile
@ -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
|
||||
|
||||
|
@ -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[];
|
||||
|
@ -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;
|
||||
|
56
pages/admin/blog/editor.vue
Normal file
56
pages/admin/blog/editor.vue
Normal 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\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>
|
@ -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"
|
||||
|
@ -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')),
|
||||
};
|
||||
});
|
||||
|
@ -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
59
src/blog/metadata.ts
Normal 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 };
|
||||
};
|
@ -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: [
|
||||
|
Loading…
x
Reference in New Issue
Block a user