mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-22 12:03:25 -04:00
Merge branch 'admin-blog-preview' into 'main'
admin blog preview See merge request PronounsPage/PronounsPage!548
This commit is contained in:
commit
9335a1bd48
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,3 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
interface Count {
|
||||
name?: string;
|
||||
count: number;
|
||||
warning?: number;
|
||||
danger?: number;
|
||||
link?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
baseUrl?: string;
|
||||
icon: string;
|
||||
header: string;
|
||||
link?: string;
|
||||
counts?: Count[];
|
||||
}>(),
|
||||
{
|
||||
baseUrl: '',
|
||||
counts: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const visibleCounts = computed((): Count[] => {
|
||||
return props.counts.filter(({ enabled }) => enabled !== false);
|
||||
});
|
||||
|
||||
const counterClass = (count: number, warning: number | undefined, danger: number | undefined): string => {
|
||||
if (danger && count >= danger) {
|
||||
return 'text-bg-danger';
|
||||
}
|
||||
if (warning && count >= warning) {
|
||||
return 'text-bg-warning';
|
||||
}
|
||||
return 'bg-light text-dark border';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="col-12 col-lg-3">
|
||||
<component :is="link ? 'a' : 'div'" :href="baseUrl + link" class="card mb-3" style="min-height: 128px;">
|
||||
@ -8,10 +47,14 @@
|
||||
{{ header }}
|
||||
</h4>
|
||||
<ul class="list-inline h5">
|
||||
<li v-for="{ name, count, warning, danger, link } in visibleCounts" class="list-inline-item">
|
||||
<li
|
||||
v-for="{ name, count, warning, danger, link: sublink } in visibleCounts"
|
||||
:key="name"
|
||||
class="list-inline-item"
|
||||
>
|
||||
<component
|
||||
:is="link ? 'a' : 'span'"
|
||||
:href="baseUrl + link"
|
||||
:is="sublink ? 'a' : 'span'"
|
||||
:href="baseUrl + sublink"
|
||||
:class="['badge', counterClass(count, warning, danger)]"
|
||||
>
|
||||
{{ count }} {{ name || '' }}
|
||||
@ -24,43 +67,3 @@
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
interface Count {
|
||||
name?: string;
|
||||
count: number;
|
||||
warning?: number;
|
||||
danger?: number;
|
||||
link?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
baseUrl: { default: '', type: String },
|
||||
icon: { required: true, type: String },
|
||||
header: { required: true, type: String },
|
||||
link: { required: false, type: String },
|
||||
counts: { default: () => [], type: Array as PropType<Count[]> },
|
||||
},
|
||||
computed: {
|
||||
visibleCounts(): Count[] {
|
||||
return this.counts.filter(({ enabled }) => enabled !== false);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
counterClass(count: number, warning: number | undefined, danger: number | undefined): string {
|
||||
if (danger && count >= danger) {
|
||||
return 'text-bg-danger';
|
||||
}
|
||||
if (warning && count >= warning) {
|
||||
return 'text-bg-warning';
|
||||
}
|
||||
return 'bg-light text-dark border';
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Columnist from 'avris-columnist';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
|
||||
import useConfig from '../composables/useConfig.ts';
|
||||
|
||||
import type { Post } from '~/server/blog.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
posts: string[] | Post[];
|
||||
details?: boolean;
|
||||
}>();
|
||||
|
||||
const { data: postsFull } = useAsyncData(`posts-${JSON.stringify(props.posts)}`, async () => {
|
||||
if (!props.posts.length) {
|
||||
return [];
|
||||
}
|
||||
if (typeof props.posts[0] === 'object') {
|
||||
return props.posts as Post[];
|
||||
}
|
||||
return await $fetch('/api/blog', {
|
||||
params: {
|
||||
slugs: props.posts,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const config = useConfig();
|
||||
|
||||
const shortcuts: Record<string, string | undefined> = {};
|
||||
if (config.blog && config.blog.shortcuts) {
|
||||
for (const shortcut in config.blog.shortcuts) {
|
||||
if (!config.blog.shortcuts.hasOwnProperty(shortcut)) {
|
||||
continue;
|
||||
}
|
||||
shortcuts[config.blog.shortcuts[shortcut]] = shortcut;
|
||||
}
|
||||
}
|
||||
|
||||
const generateLink = (slug: string): RouteLocationRaw => {
|
||||
const keepFullPath = config.blog?.keepFullPath || [];
|
||||
return shortcuts[slug] !== undefined && !keepFullPath.includes(slug)
|
||||
? `/${shortcuts[slug]}`
|
||||
: { name: 'blogEntry', params: { slug } };
|
||||
};
|
||||
|
||||
const entries = useTemplateRef<HTMLDivElement>('entries');
|
||||
onMounted(async () => {
|
||||
if (entries.value) {
|
||||
const columnist = new Columnist(entries.value);
|
||||
columnist.start();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="entries" class="columnist-wall row">
|
||||
<div v-for="post in postsFull" class="columnist-column col-12 col-sm-6 col-md-4 mb-3">
|
||||
<div class="card shadow">
|
||||
<nuxt-link v-if="post.hero" :to="generateLink(post.slug)">
|
||||
<img :src="post.hero.src" :class="['w-100', post.hero.class]" :alt="post.hero.alt" loading="lazy">
|
||||
</nuxt-link>
|
||||
<nuxt-link :to="generateLink(post.slug)" class="card-body text-center h4 p-3 mb-0 post-title">
|
||||
<Spelling :text="post.title" />
|
||||
</nuxt-link>
|
||||
<div v-if="details" class="card-footer small">
|
||||
<ul class="list-inline mb-0">
|
||||
<li class="list-inline-item small">
|
||||
<Icon v="calendar" />
|
||||
{{ post.date }}
|
||||
</li>
|
||||
<li v-for="author in post.authors" class="list-inline-item">
|
||||
<nuxt-link v-if="author.startsWith('@')" :to="`/${author}`" class="badge bg-light text-dark border">
|
||||
<Icon v="collective-logo.svg" class="invertible" />
|
||||
{{ author }}
|
||||
</nuxt-link>
|
||||
<span v-else class="badge bg-light text-dark border">
|
||||
{{ author }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.columnist-wall > .columnist-column {
|
||||
transition: margin-top .2s ease-in-out;
|
||||
}
|
||||
.post-title {
|
||||
text-wrap: balance;
|
||||
}
|
||||
</style>
|
@ -56,7 +56,7 @@ const formatDuration = (secondsCount: number): string => {
|
||||
|
|
||||
<strong><T>footer.stats.current</T></strong>
|
||||
</p>
|
||||
<ul :key="`${overall}`" class="list-unstyled">
|
||||
<ul v-if="activeStats" :key="`${overall}`" class="list-unstyled">
|
||||
<li v-if="activeStats.cards" class="mb-2">
|
||||
<Icon v="id-card" />
|
||||
<T>footer.stats.keys.cards</T><T>quotation.colon</T>
|
||||
|
49
components/blog/BlogEntriesList.vue
Normal file
49
components/blog/BlogEntriesList.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import Columnist from 'avris-columnist';
|
||||
|
||||
import type { Post } from '~/src/blog/metadata.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
posts: string[] | Post[];
|
||||
details?: boolean;
|
||||
}>();
|
||||
|
||||
const { data: postsFull } = useAsyncData(`posts-${JSON.stringify(props.posts)}`, async () => {
|
||||
if (!props.posts.length) {
|
||||
return [];
|
||||
}
|
||||
if (typeof props.posts[0] === 'object') {
|
||||
return props.posts as Post[];
|
||||
}
|
||||
return await $fetch('/api/blog', {
|
||||
params: {
|
||||
slugs: props.posts,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const entries = useTemplateRef<HTMLDivElement>('entries');
|
||||
onMounted(async () => {
|
||||
if (entries.value) {
|
||||
const columnist = new Columnist(entries.value);
|
||||
columnist.start();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="entries" class="columnist-wall row">
|
||||
<div v-for="post in postsFull" :key="post.slug" class="columnist-column col-12 col-sm-6 col-md-4 mb-3">
|
||||
<BlogEntry :post :details />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.columnist-wall > .columnist-column {
|
||||
transition: margin-top .2s ease-in-out;
|
||||
}
|
||||
.post-title {
|
||||
text-wrap: balance;
|
||||
}
|
||||
</style>
|
51
components/blog/BlogEntry.vue
Normal file
51
components/blog/BlogEntry.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
|
||||
import useConfig from '~/composables/useConfig.ts';
|
||||
import type { Post } from '~/src/blog/metadata.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
post: Post;
|
||||
details?: boolean;
|
||||
}>();
|
||||
|
||||
const config = useConfig();
|
||||
|
||||
const link = computed((): RouteLocationRaw => {
|
||||
const shortcuts = config.blog?.shortcuts ?? {};
|
||||
const keepFullPath = config.blog?.keepFullPath ?? [];
|
||||
const shortcut = Object.entries(shortcuts)
|
||||
.find(([_, slug]) => slug === props.post.slug);
|
||||
return shortcut !== undefined && !keepFullPath.includes(props.post.slug)
|
||||
? `/${shortcut[0]}`
|
||||
: { name: 'blogEntry', params: { slug: props.post.slug } };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card shadow">
|
||||
<nuxt-link v-if="post.hero" :to="link">
|
||||
<img :src="post.hero.src" :class="['w-100', post.hero.class]" :alt="post.hero.alt" loading="lazy">
|
||||
</nuxt-link>
|
||||
<nuxt-link :to="link" class="card-body text-center h4 p-3 mb-0 post-title">
|
||||
<Spelling :text="post.title" />
|
||||
</nuxt-link>
|
||||
<div v-if="details" class="card-footer small">
|
||||
<ul class="list-inline mb-0">
|
||||
<li class="list-inline-item small">
|
||||
<Icon v="calendar" />
|
||||
{{ post.date }}
|
||||
</li>
|
||||
<li v-for="author in post.authors" class="list-inline-item">
|
||||
<nuxt-link v-if="author.startsWith('@')" :to="`/${author}`" class="badge bg-light text-dark border">
|
||||
<Icon v="collective-logo.svg" class="invertible" />
|
||||
{{ author }}
|
||||
</nuxt-link>
|
||||
<span v-else class="badge bg-light text-dark border">
|
||||
{{ author }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
58
pages/admin/blog/editor.vue
Normal file
58
pages/admin/blog/editor.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<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" rows="10"></textarea>
|
||||
<template v-if="post">
|
||||
<hr>
|
||||
<BlogEntry class="col-12 col-sm-6 col-md-4" :post details />
|
||||
</template>
|
||||
<hr>
|
||||
<div class="blog-post">
|
||||
<Spelling v-if="parsed?.content" :text="parsed.content" />
|
||||
</div>
|
||||
</template>
|
||||
</Page>
|
||||
</template>
|
@ -1,3 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { useNuxtApp } from 'nuxt/app';
|
||||
|
||||
import useSimpleHead from '~/composables/useSimpleHead.ts';
|
||||
import type { LocalDescriptionWithConfig } from '~/server/admin.ts';
|
||||
import { longtimeCookieSetting } from '~/src/cookieSettings.ts';
|
||||
import { useMainStore } from '~/store/index.ts';
|
||||
|
||||
const { $translator: translator, $isGranted: isGranted } = useNuxtApp();
|
||||
useSimpleHead({
|
||||
title: translator.translate('admin.header'),
|
||||
}, translator);
|
||||
|
||||
const tokenCookie = useCookie('token', longtimeCookieSetting);
|
||||
const impersonatorCookie = useCookie('impersonator', longtimeCookieSetting);
|
||||
|
||||
const statsAsyncData = useFetch('/api/admin/stats');
|
||||
const allLocalesAsyncData = useFetch('/api/admin/all-locales');
|
||||
await Promise.all([statsAsyncData, allLocalesAsyncData]);
|
||||
const stats = statsAsyncData.data;
|
||||
const allLocales = allLocalesAsyncData.data;
|
||||
|
||||
const store = useMainStore();
|
||||
|
||||
const adminNotifications = ref(store.user ? store.user.adminNotifications ?? 7 : 7);
|
||||
watch(adminNotifications, async () => {
|
||||
const res = await $fetch('/api/admin/set-notification-frequency', {
|
||||
method: 'POST',
|
||||
body: { frequency: adminNotifications.value },
|
||||
});
|
||||
await store.setToken(res.token);
|
||||
});
|
||||
|
||||
const filterAttention = ref<boolean>(false);
|
||||
|
||||
const visibleLocales = computed((): Record<string, LocalDescriptionWithConfig> | null => {
|
||||
if (allLocales.value === null) {
|
||||
return null;
|
||||
}
|
||||
return Object.fromEntries(Object.entries(allLocales.value).filter(([locale, _]) => {
|
||||
return isGranted('panel', locale) && statsAsyncData.data.value?.locales[locale];
|
||||
}));
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const impersonate = async (email: string) => {
|
||||
const { token } = await $fetch(`/api/admin/impersonate/${encodeURIComponent(email)}`);
|
||||
impersonatorCookie.value = tokenCookie.value;
|
||||
tokenCookie.value = token;
|
||||
await router.push({ name: 'user' });
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page wide>
|
||||
<NotFound v-if="!$isGranted('panel')" />
|
||||
@ -9,10 +63,10 @@
|
||||
<T>admin.header</T>
|
||||
</h2>
|
||||
|
||||
<p v-if="stats.calculatedAt != null" class="small">
|
||||
<p v-if="stats" class="small">
|
||||
<em>
|
||||
Stats calculated at:
|
||||
<span :class="stats.calculatedAt < (new Date() - 24 * 60 * 60 * 1000) / 1000 ? `badge bg-danger text-white` : ''">
|
||||
<span :class="stats.calculatedAt < (new Date().getTime() - 24 * 60 * 60 * 1000) / 1000 ? `badge bg-danger text-white` : ''">
|
||||
{{ $datetime(stats.calculatedAt) }}
|
||||
</span>
|
||||
</em>
|
||||
@ -31,34 +85,34 @@
|
||||
|
||||
<div class="row mb-4">
|
||||
<AdminDashboardCard
|
||||
v-if="$isGranted('users') || $isGranted('community')"
|
||||
v-if="stats && ($isGranted('users') || $isGranted('community'))"
|
||||
v-show="!filterAttention"
|
||||
icon="users"
|
||||
header="Users"
|
||||
link="/admin/users"
|
||||
:counts="[
|
||||
{ count: stats._.users },
|
||||
{ name: 'admins', count: stats._.admins },
|
||||
{ count: stats.overall.users },
|
||||
{ name: 'admins', count: stats.overall.admins },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
v-if="$isGranted('users') || $isGranted('community')"
|
||||
v-show="!filterAttention || stats._.bansPending"
|
||||
v-if="stats && ($isGranted('users') || $isGranted('community'))"
|
||||
v-show="!filterAttention || stats.overall.bansPending"
|
||||
icon="ban"
|
||||
header="Pending bans"
|
||||
link="/admin/pending-bans"
|
||||
:counts="[
|
||||
{ count: stats._.bansPending, warning: 1, danger: 16 },
|
||||
{ count: stats.overall.bansPending, warning: 1, danger: 16 },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
v-if="$isGranted('users') || $isGranted('community')"
|
||||
v-show="!filterAttention || stats._.userReports"
|
||||
v-if="stats && ($isGranted('users') || $isGranted('community'))"
|
||||
v-show="!filterAttention || stats.overall.userReports"
|
||||
icon="siren-on"
|
||||
header="Abuse reports"
|
||||
link="/admin/abuse-reports"
|
||||
:counts="[
|
||||
{ count: stats._.userReports, warning: 1, danger: 16 },
|
||||
{ count: stats.overall.userReports, warning: 1, danger: 16 },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
@ -68,21 +122,21 @@
|
||||
header="Moderation rules"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
v-if="$isGranted('code')"
|
||||
v-show="!filterAttention || stats._.cardsQueue"
|
||||
v-if="stats && $isGranted('code')"
|
||||
v-show="!filterAttention || stats.overall.cardsQueue"
|
||||
icon="id-card"
|
||||
header="Cards queue"
|
||||
:counts="[
|
||||
{ count: stats._.cardsQueue, warning: 16, danger: 64 },
|
||||
{ count: stats.overall.cardsQueue, warning: 16, danger: 64 },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
v-if="$isGranted('code')"
|
||||
v-show="!filterAttention || stats._.linksQueue"
|
||||
v-if="stats && $isGranted('code')"
|
||||
v-show="!filterAttention || stats.overall.linksQueue"
|
||||
icon="link"
|
||||
header="Links queue"
|
||||
:counts="[
|
||||
{ count: stats._.linksQueue, warning: 64, danger: 256 },
|
||||
{ count: stats.overall.linksQueue, warning: 64, danger: 256 },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
@ -132,85 +186,95 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-for="({ name, config, url, published }, locale) in visibleLocales">
|
||||
<template v-for="({ name, config, url, published }, locale) in visibleLocales" :key="locale">
|
||||
<h3>
|
||||
{{ name }}
|
||||
<small v-if="!published" class="text-muted">(not published yet)</small>
|
||||
</h3>
|
||||
<div class="row mb-4">
|
||||
<AdminDashboardCard
|
||||
v-if="stats"
|
||||
v-show="!filterAttention"
|
||||
:base-url="url"
|
||||
icon="users"
|
||||
header="Profiles"
|
||||
link="/admin/profiles"
|
||||
:counts="[
|
||||
{ count: stats[locale].users },
|
||||
{ count: stats.locales[locale].users },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
v-if="config.nouns && config.nouns.enabled && $isGranted('nouns', locale) && (stats[locale].nouns.approved > 0 || stats[locale].nouns.awaiting > 0)"
|
||||
v-show="!filterAttention || stats[locale].nouns.awaiting"
|
||||
v-if="stats && config.nouns && config.nouns.enabled && $isGranted('nouns', locale) && (stats.locales[locale].nouns.approved > 0 || stats.locales[locale].nouns.awaiting > 0)"
|
||||
v-show="!filterAttention || stats.locales[locale].nouns.awaiting"
|
||||
:base-url="url"
|
||||
icon="book"
|
||||
header="Dictionary"
|
||||
:link="`/${config.nouns.route}`"
|
||||
:counts="[
|
||||
{ count: stats[locale].nouns.approved },
|
||||
{ name: 'awaiting', count: stats[locale].nouns.awaiting, warning: 1, danger: 16 },
|
||||
{ count: stats.locales[locale].nouns.approved },
|
||||
{ name: 'awaiting', count: stats.locales[locale].nouns.awaiting, warning: 1, danger: 16 },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
v-if="config.inclusive && config.inclusive.enabled && $isGranted('inclusive', locale) && (stats[locale].inclusive.approved > 0 || stats[locale].inclusive.awaiting > 0)"
|
||||
v-show="!filterAttention || stats[locale].inclusive.awaiting"
|
||||
v-if="stats && config.inclusive && config.inclusive.enabled && $isGranted('inclusive', locale) && (stats.locales[locale].inclusive.approved > 0 || stats.locales[locale].inclusive.awaiting > 0)"
|
||||
v-show="!filterAttention || stats.locales[locale].inclusive.awaiting"
|
||||
:base-url="url"
|
||||
icon="book-heart"
|
||||
header="Inclusive"
|
||||
:link="`/${config.inclusive.route}`"
|
||||
:counts="[
|
||||
{ count: stats[locale].inclusive.approved },
|
||||
{ name: 'awaiting', count: stats[locale].inclusive.awaiting, warning: 1, danger: 16 },
|
||||
{ count: stats.locales[locale].inclusive.approved },
|
||||
{ name: 'awaiting', count: stats.locales[locale].inclusive.awaiting, warning: 1, danger: 16 },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
v-if="config.terminology && config.terminology.enabled && $isGranted('terms', locale) && (stats[locale].terms.approved > 0 || stats[locale].terms.awaiting > 0)"
|
||||
v-show="!filterAttention || stats[locale].terms.awaiting"
|
||||
v-if="stats && config.terminology && config.terminology.enabled && $isGranted('terms', locale) && (stats.locales[locale].terms.approved > 0 || stats.locales[locale].terms.awaiting > 0)"
|
||||
v-show="!filterAttention || stats.locales[locale].terms.awaiting"
|
||||
:base-url="url"
|
||||
icon="flag"
|
||||
header="Terminology"
|
||||
:link="`/${config.terminology.route}`"
|
||||
:counts="[
|
||||
{ count: stats[locale].terms.approved },
|
||||
{ name: 'awaiting', count: stats[locale].terms.awaiting, warning: 1, danger: 16 },
|
||||
{ count: stats.locales[locale].terms.approved },
|
||||
{ name: 'awaiting', count: stats.locales[locale].terms.awaiting, warning: 1, danger: 16 },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
v-if="config.sources && config.sources.enabled && $isGranted('sources', locale) && (stats[locale].sources.approved > 0 || stats[locale].sources.awaiting > 0)"
|
||||
v-show="!filterAttention || stats[locale].sources.awaiting"
|
||||
v-if="stats && config.sources && config.sources.enabled && $isGranted('sources', locale) && (stats.locales[locale].sources.approved > 0 || stats.locales[locale].sources.awaiting > 0)"
|
||||
v-show="!filterAttention || stats.locales[locale].sources.awaiting"
|
||||
:base-url="url"
|
||||
icon="books"
|
||||
header="Sources"
|
||||
:link="`/${config.sources.route}`"
|
||||
:counts="[
|
||||
{ count: stats[locale].sources.approved },
|
||||
{ name: 'awaiting', count: stats[locale].sources.awaiting, warning: 1, danger: 16 },
|
||||
{ count: stats.locales[locale].sources.approved },
|
||||
{ name: 'awaiting', count: stats.locales[locale].sources.awaiting, warning: 1, danger: 16 },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
v-if="config.names && config.names.enabled && $isGranted('names', locale) && (stats[locale].names.approved > 0 || stats[locale].names.awaiting > 0)"
|
||||
v-show="!filterAttention || stats[locale].names.awaiting"
|
||||
v-if="stats && config.names && config.names.enabled && $isGranted('names', locale) && (stats.locales[locale].names.approved > 0 || stats.locales[locale].names.awaiting > 0)"
|
||||
v-show="!filterAttention || stats.locales[locale].names.awaiting"
|
||||
:base-url="url"
|
||||
icon="signature"
|
||||
header="Names"
|
||||
:link="`/${config.names.route}`"
|
||||
:counts="[
|
||||
{ count: stats[locale].names.approved },
|
||||
{ name: 'awaiting', count: stats[locale].names.awaiting, warning: 1, danger: 16 },
|
||||
{ count: stats.locales[locale].names.approved },
|
||||
{ name: 'awaiting', count: stats.locales[locale].names.awaiting, warning: 1, danger: 16 },
|
||||
]"
|
||||
/>
|
||||
<AdminDashboardCard
|
||||
v-if="$isGranted('translations', locale) && (stats[locale].translations.missing > 0 || stats[locale].translations.awaitingApproval > 0) || $isGranted('code', locale) && stats[locale].translations.awaitingMerge > 0"
|
||||
v-show="!filterAttention || stats[locale].translations.missing || stats[locale].translations.awaitingApproval || stats[locale].translations.awaitingMerge"
|
||||
v-if="config.links.enabled && config.links.blog"
|
||||
v-show="!filterAttention"
|
||||
: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"
|
||||
:base-url="url"
|
||||
icon="language"
|
||||
header="Translations"
|
||||
@ -218,7 +282,7 @@
|
||||
{
|
||||
name: 'missing',
|
||||
link: '/admin/translations/missing',
|
||||
count: stats[locale].translations.missing,
|
||||
count: stats.locales[locale].translations.missing,
|
||||
warning: 1,
|
||||
danger: 16,
|
||||
enabled: $isGranted('translations', locale),
|
||||
@ -226,7 +290,7 @@
|
||||
{
|
||||
name: 'proposed',
|
||||
link: '/admin/translations/awaiting',
|
||||
count: stats[locale].translations.awaitingApproval,
|
||||
count: stats.locales[locale].translations.awaitingApproval,
|
||||
warning: 1,
|
||||
danger: 16,
|
||||
enabled: $isGranted('translations', locale),
|
||||
@ -234,7 +298,7 @@
|
||||
{
|
||||
name: 'not merged',
|
||||
link: '/admin/translations/awaiting',
|
||||
count: stats[locale].translations.awaitingMerge,
|
||||
count: stats.locales[locale].translations.awaitingMerge,
|
||||
warning: 1,
|
||||
danger: 16,
|
||||
enabled: $isGranted('code', locale),
|
||||
@ -246,68 +310,3 @@
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useNuxtApp } from 'nuxt/app';
|
||||
|
||||
import useConfig from '~/composables/useConfig.ts';
|
||||
import useSimpleHead from '~/composables/useSimpleHead.ts';
|
||||
import { longtimeCookieSetting } from '~/src/cookieSettings.ts';
|
||||
import { useMainStore } from '~/store/index.ts';
|
||||
|
||||
export default {
|
||||
async setup() {
|
||||
const { $translator: translator } = useNuxtApp();
|
||||
useSimpleHead({
|
||||
title: translator.translate('admin.header'),
|
||||
}, translator);
|
||||
|
||||
const tokenCookie = useCookie('token', longtimeCookieSetting);
|
||||
const impersonatorCookie = useCookie('impersonator', longtimeCookieSetting);
|
||||
|
||||
const stats = useFetch('/api/admin/stats');
|
||||
const allLocales = useFetch('/api/admin/all-locales');
|
||||
await Promise.all([stats, allLocales]);
|
||||
|
||||
return {
|
||||
config: useConfig(),
|
||||
store: useMainStore(),
|
||||
tokenCookie,
|
||||
impersonatorCookie,
|
||||
stats: stats.data.value,
|
||||
allLocales: allLocales.data.value,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
adminNotifications: this.$user() ? this.$user().adminNotifications ?? 7 : 7,
|
||||
filterAttention: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
visibleLocales() {
|
||||
return Object.fromEntries(Object.entries(this.allLocales).filter(([locale, _]) => {
|
||||
return this.$isGranted('panel', locale) && this.stats[locale];
|
||||
}));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
async adminNotifications() {
|
||||
const res = await $fetch('/api/admin/set-notification-frequency', {
|
||||
method: 'POST',
|
||||
body: { frequency: parseInt(this.adminNotifications) },
|
||||
});
|
||||
await this.store.setToken(res.token);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async impersonate(email) {
|
||||
const { token } = await $fetch(`/api/admin/impersonate/${encodeURIComponent(email)}`);
|
||||
this.impersonatorCookie = this.tokenCookie;
|
||||
this.tokenCookie = token;
|
||||
await this.$router.push({ name: 'user' });
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
51
server/admin.ts
Normal file
51
server/admin.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import SQL from 'sql-template-strings';
|
||||
import { decodeTime } from 'ulid';
|
||||
|
||||
import type { Config } from '~/locale/config.ts';
|
||||
import type { LocaleDescription } from '~/locale/locales.ts';
|
||||
import type { Database } from '~/server/db.ts';
|
||||
import type { LocaleStatsData, OverallStatsData } from '~/src/stats.ts';
|
||||
|
||||
export type LocalDescriptionWithConfig = Omit<LocaleDescription, 'matches'> & { config: Config };
|
||||
|
||||
export interface StatRow {
|
||||
id: string;
|
||||
locale: string;
|
||||
users: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
calculatedAt: number;
|
||||
overall: { users: number } & OverallStatsData;
|
||||
locales: Record<string, { users: number } & LocaleStatsData>;
|
||||
}
|
||||
|
||||
export const fetchStats = async (db: Database): Promise<Stats | null> => {
|
||||
const maxId = (await db.get<{ maxId: StatRow['id'] | null }>('SELECT MAX(id) AS maxId FROM stats'))!.maxId;
|
||||
|
||||
if (maxId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let overall: ({ users: number } & OverallStatsData) | null = null;
|
||||
const locales: Record<string, { users: number } & LocaleStatsData> = {};
|
||||
|
||||
for (const statsRow of await db.all<Pick<StatRow, 'locale' | 'users' | 'data'>>(SQL`SELECT locale, users, data FROM stats WHERE id = ${maxId}`)) {
|
||||
const stats = {
|
||||
users: statsRow.users,
|
||||
...JSON.parse(statsRow.data),
|
||||
};
|
||||
if (statsRow.locale === '_') {
|
||||
overall = stats;
|
||||
} else {
|
||||
locales[statsRow.locale] = stats;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
calculatedAt: decodeTime(maxId) / 1000,
|
||||
overall: overall!,
|
||||
locales,
|
||||
};
|
||||
};
|
24
server/api/admin/all-locales.get.ts
Normal file
24
server/api/admin/all-locales.get.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import fs from 'fs';
|
||||
|
||||
import Suml from 'suml';
|
||||
|
||||
import type { Config } from '~/locale/config.ts';
|
||||
import buildLocaleList from '~/src/buildLocaleList.ts';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { isGranted } = await useAuthentication(event);
|
||||
if (!isGranted('panel')) {
|
||||
throw createError({
|
||||
status: 401,
|
||||
statusMessage: 'Unauthorised',
|
||||
});
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(buildLocaleList(global.config.locale, true))
|
||||
.map(([locale, localeDescription]) => {
|
||||
return [locale, {
|
||||
...localeDescription,
|
||||
config: new Suml().parse(fs.readFileSync(`./locale/${locale}/config.suml`).toString()) as Config,
|
||||
}];
|
||||
}));
|
||||
});
|
31
server/api/admin/moderation.get.ts
Normal file
31
server/api/admin/moderation.get.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import markdownit from 'markdown-it';
|
||||
|
||||
import { rootDir } from '~/server/paths.ts';
|
||||
|
||||
const md = markdownit({ html: true });
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { isGranted } = await useAuthentication(event);
|
||||
if (!isGranted('panel')) {
|
||||
throw createError({
|
||||
status: 401,
|
||||
statusMessage: 'Unauthorised',
|
||||
});
|
||||
}
|
||||
|
||||
const dir = `${rootDir}/moderation`;
|
||||
|
||||
return {
|
||||
susRegexes: (await fs.readFile(`${dir}/sus.txt`, 'utf-8'))
|
||||
.split('\n')
|
||||
.filter((x) => !!x && !x.startsWith('#')),
|
||||
rulesUsers: md.render(await fs.readFile(`${dir}/rules-users.md`, 'utf-8')),
|
||||
rulesTerminology: md.render(await fs.readFile(`${dir}/rules-terminology.md`, 'utf-8')),
|
||||
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')),
|
||||
};
|
||||
});
|
67
server/api/admin/stats-public.get.ts
Normal file
67
server/api/admin/stats-public.get.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { fetchStats } from '~/server/admin.ts';
|
||||
|
||||
interface PublicStats {
|
||||
calculatedAt: number;
|
||||
overall: PublicLocaleStats;
|
||||
current: Partial<PublicLocaleStats>;
|
||||
}
|
||||
|
||||
interface PublicLocaleStats {
|
||||
users: number;
|
||||
cards: number;
|
||||
pageViews: number;
|
||||
visitors: number;
|
||||
online: number;
|
||||
visitDuration?: number;
|
||||
uptime?: number;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const db = useDatabase();
|
||||
const statsAll = await fetchStats(db);
|
||||
if (statsAll === null) {
|
||||
throw createError({
|
||||
status: 404,
|
||||
statusMessage: 'Not Found',
|
||||
});
|
||||
}
|
||||
|
||||
const stats: PublicStats = {
|
||||
calculatedAt: statsAll.calculatedAt,
|
||||
overall: {
|
||||
users: statsAll.overall.users,
|
||||
cards: 0,
|
||||
pageViews: statsAll.overall.plausible?.pageviews || 0,
|
||||
visitors: statsAll.overall.plausible?.visitors || 0,
|
||||
online: statsAll.overall.plausible?.realTimeVisitors || 0,
|
||||
},
|
||||
current: {},
|
||||
};
|
||||
|
||||
for (const [locale, localeStats] of Object.entries(statsAll.locales)) {
|
||||
stats.overall.cards += localeStats.users;
|
||||
if (locale === global.config.locale) {
|
||||
stats.current = {
|
||||
cards: localeStats.users,
|
||||
};
|
||||
}
|
||||
if (localeStats.plausible) {
|
||||
stats.overall.pageViews += localeStats.plausible.pageviews;
|
||||
stats.overall.visitors += localeStats.plausible.visitors;
|
||||
stats.overall.online += localeStats.plausible.realTimeVisitors;
|
||||
if (locale === global.config.locale) {
|
||||
stats.current.pageViews = localeStats.plausible.pageviews;
|
||||
stats.current.visitors = localeStats.plausible.visitors;
|
||||
stats.current.online = localeStats.plausible.realTimeVisitors;
|
||||
stats.current.visitDuration = localeStats.plausible.visit_duration;
|
||||
}
|
||||
}
|
||||
if (localeStats.heartbeat && locale === global.config.locale) {
|
||||
stats.current.uptime = localeStats.heartbeat.uptime;
|
||||
stats.current.responseTime = localeStats.heartbeat.avgResponseTime;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
28
server/api/admin/stats.get.ts
Normal file
28
server/api/admin/stats.get.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { fetchStats } from '~/server/admin.ts';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { isGranted } = await useAuthentication(event);
|
||||
if (!isGranted('panel')) {
|
||||
throw createError({
|
||||
status: 401,
|
||||
statusMessage: 'Unauthorised',
|
||||
});
|
||||
}
|
||||
|
||||
const db = useDatabase();
|
||||
const stats = await fetchStats(db);
|
||||
if (stats === null) {
|
||||
throw createError({
|
||||
status: 404,
|
||||
statusMessage: 'Not Found',
|
||||
});
|
||||
}
|
||||
|
||||
for (const locale of Object.keys(stats.locales)) {
|
||||
if (!isGranted('panel', locale)) {
|
||||
delete stats.locales[locale];
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
@ -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`;
|
||||
|
@ -1,28 +1,21 @@
|
||||
import fs from 'fs';
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { Request } from 'express';
|
||||
import markdownit from 'markdown-it';
|
||||
import SQL from 'sql-template-strings';
|
||||
import Suml from 'suml';
|
||||
import { encodeTime, decodeTime, ulid } from 'ulid';
|
||||
|
||||
import type { Config } from '../../locale/config.ts';
|
||||
import allLocales from '../../locale/locales.ts';
|
||||
import type { LocaleDescription } from '../../locale/locales.ts';
|
||||
import buildLocaleList from '../../src/buildLocaleList.ts';
|
||||
import { buildDict, now, shuffle, handleErrorAsync, filterObjectKeys } from '../../src/helpers.ts';
|
||||
import { auditLog, fetchAuditLog } from '../audit.ts';
|
||||
import avatar from '../avatar.ts';
|
||||
import { archiveBan, liftBan } from '../ban.ts';
|
||||
import type { Database } from '../db.ts';
|
||||
import mailer from '../mailer.ts';
|
||||
import { rootDir } from '../paths.ts';
|
||||
|
||||
import { profilesSnapshot } from './profile.ts';
|
||||
import { loadCurrentUser } from './user.ts';
|
||||
import type { UserRow } from './user.ts';
|
||||
|
||||
import type { StatRow } from '~/server/admin.ts';
|
||||
|
||||
interface BanProposalRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
@ -31,13 +24,6 @@ interface BanProposalRow {
|
||||
bannedReason: string;
|
||||
}
|
||||
|
||||
interface StatRow {
|
||||
id: string;
|
||||
locale: string;
|
||||
users: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface UserMessageRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
@ -171,112 +157,6 @@ router.get('/admin/users', handleErrorAsync(async (req, res) => {
|
||||
});
|
||||
}));
|
||||
|
||||
const fetchStats = async (req: Request): Promise<any> => {
|
||||
const maxId = (await req.db.get<{ maxId: StatRow['id'] | null }>('SELECT MAX(id) AS maxId FROM stats'))!.maxId;
|
||||
|
||||
if (maxId == null) {
|
||||
return {
|
||||
_: {},
|
||||
};
|
||||
}
|
||||
|
||||
const stats: any = {
|
||||
calculatedAt: decodeTime(maxId) / 1000,
|
||||
};
|
||||
|
||||
for (const statsRow of await req.db.all<Pick<StatRow, 'locale' | 'users' | 'data'>>(SQL`SELECT locale, users, data FROM stats WHERE id = ${maxId}`)) {
|
||||
stats[statsRow.locale] = {
|
||||
users: statsRow.users,
|
||||
...JSON.parse(statsRow.data),
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
router.get('/admin/stats', handleErrorAsync(async (req, res) => {
|
||||
if (!req.isGranted('panel')) {
|
||||
return res.status(401).json({ error: 'Unauthorised' });
|
||||
}
|
||||
|
||||
const stats = await fetchStats(req);
|
||||
|
||||
for (const locale of Object.keys(stats)) {
|
||||
if (locale === '_' || locale === 'calculatedAt') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!req.isGranted('panel', locale)) {
|
||||
delete stats[locale];
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(stats);
|
||||
}));
|
||||
|
||||
interface Stats {
|
||||
calculatedAt: number;
|
||||
overall: LocaleStats;
|
||||
current: Partial<LocaleStats>;
|
||||
}
|
||||
|
||||
interface LocaleStats {
|
||||
users: number;
|
||||
cards: number;
|
||||
pageViews?: number;
|
||||
visitors?: number;
|
||||
online?: number;
|
||||
visitDuration?: number;
|
||||
uptime?: number;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
router.get('/admin/stats-public', handleErrorAsync(async (req, res) => {
|
||||
const statsAll = await fetchStats(req);
|
||||
|
||||
const stats: Stats = {
|
||||
calculatedAt: statsAll.calculatedAt,
|
||||
overall: {
|
||||
users: statsAll._.users,
|
||||
cards: 0,
|
||||
pageViews: statsAll._.plausible?.pageviews || 0,
|
||||
visitors: statsAll._.plausible?.visitors || 0,
|
||||
online: statsAll._.plausible?.realTimeVisitors || 0,
|
||||
},
|
||||
current: {},
|
||||
};
|
||||
|
||||
for (const [locale, localeStats] of Object.entries(statsAll) as any) {
|
||||
if (locale === '_' || locale === 'calculatedAt') {
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.overall.cards += localeStats.users;
|
||||
if (locale === global.config.locale) {
|
||||
stats.current = {
|
||||
cards: localeStats.users,
|
||||
};
|
||||
}
|
||||
if (localeStats.plausible) {
|
||||
stats.overall.pageViews += localeStats.plausible.pageviews;
|
||||
stats.overall.visitors += localeStats.plausible.visitors;
|
||||
stats.overall.online += localeStats.plausible.realTimeVisitors;
|
||||
if (locale === global.config.locale) {
|
||||
stats.current.pageViews = localeStats.plausible.pageviews;
|
||||
stats.current.visitors = localeStats.plausible.visitors;
|
||||
stats.current.online = localeStats.plausible.realTimeVisitors;
|
||||
stats.current.visitDuration = localeStats.plausible.visit_duration;
|
||||
}
|
||||
}
|
||||
if (localeStats.heartbeat && locale === global.config.locale) {
|
||||
stats.current.uptime = localeStats.heartbeat.uptime;
|
||||
stats.current.responseTime = localeStats.heartbeat.avgResponseTime;
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(stats);
|
||||
}));
|
||||
|
||||
router.get('/admin/stats/users-chart/:locale', handleErrorAsync(async (req, res) => {
|
||||
if (!req.isGranted('users') && !req.isGranted('community')) {
|
||||
return res.status(401).json({ error: 'Unauthorised' });
|
||||
@ -307,24 +187,6 @@ router.get('/admin/stats/users-chart/:locale', handleErrorAsync(async (req, res)
|
||||
return res.json(incrementsChart);
|
||||
}));
|
||||
|
||||
type LocalDescriptionWithConfig = LocaleDescription & { config?: Config };
|
||||
|
||||
router.get('/admin/all-locales', handleErrorAsync(async (req, res) => {
|
||||
if (!req.isGranted('panel')) {
|
||||
return res.status(401).json({ error: 'Unauthorised' });
|
||||
}
|
||||
|
||||
const locales: Record<string, LocalDescriptionWithConfig> = buildLocaleList(global.config.locale, true);
|
||||
for (const locale in locales) {
|
||||
if (!locales.hasOwnProperty(locale)) {
|
||||
continue;
|
||||
}
|
||||
locales[locale].config = new Suml().parse(fs.readFileSync(`./locale/${locale}/config.suml`).toString()) as Config;
|
||||
}
|
||||
|
||||
return res.json(locales);
|
||||
}));
|
||||
|
||||
const normalise = (s: string): string => s.trim().toLowerCase();
|
||||
|
||||
const fetchUserByUsername = async (db: Database, username: string) => {
|
||||
@ -629,27 +491,6 @@ router.post('/admin/overwrite-sensitive/:username', handleErrorAsync(async (req,
|
||||
return res.json(req.body.sensitive);
|
||||
}));
|
||||
|
||||
const md = markdownit({ html: true });
|
||||
|
||||
router.get('/admin/moderation', handleErrorAsync(async (req, res) => {
|
||||
if (!req.isGranted('panel')) {
|
||||
return res.status(401).json({ error: 'Unauthorised' });
|
||||
}
|
||||
|
||||
const dir = `${rootDir}/moderation`;
|
||||
|
||||
return res.json({
|
||||
susRegexes: fs.readFileSync(`${dir}/sus.txt`).toString('utf-8')
|
||||
.split('\n')
|
||||
.filter((x) => !!x && !x.startsWith('#')),
|
||||
rulesUsers: md.render(fs.readFileSync(`${dir}/rules-users.md`).toString('utf-8')),
|
||||
rulesTerminology: md.render(fs.readFileSync(`${dir}/rules-terminology.md`).toString('utf-8')),
|
||||
rulesSources: md.render(fs.readFileSync(`${dir}/rules-sources.md`).toString('utf-8')),
|
||||
timesheets: md.render(fs.readFileSync(`${dir}/timesheets.md`).toString('utf-8')),
|
||||
expenses: md.render(fs.readFileSync(`${dir}/expenses.md`).toString('utf-8')),
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/admin/set-notification-frequency', handleErrorAsync(async (req, res) => {
|
||||
if (!req.isGranted()) {
|
||||
return res.status(401).json({ error: 'Unauthorised' });
|
||||
|
@ -12,7 +12,6 @@ import SQL from 'sql-template-strings';
|
||||
import buildLocaleList from '../src/buildLocaleList.ts';
|
||||
import { longtimeCookieSetting } from '../src/cookieSettings.ts';
|
||||
import formatError from '../src/error.ts';
|
||||
import { isGrantedForUser } from '../src/helpers.ts';
|
||||
import type { User } from '../src/user.ts';
|
||||
|
||||
import './globals.ts';
|
||||
@ -119,9 +118,7 @@ router.use(async function (req, res, next) {
|
||||
const authentication = await useAuthentication(getH3Event(req));
|
||||
req.rawUser = authentication.rawUser;
|
||||
req.user = authentication.user;
|
||||
req.isGranted = (area: string = '', locale = global.config.locale): boolean => {
|
||||
return !!req.user && isGrantedForUser(req.user, locale, area);
|
||||
};
|
||||
req.isGranted = authentication.isGranted;
|
||||
req.locales = buildLocaleList(global.config.locale, global.config.locale === '_');
|
||||
req.db = new LazyDatabase();
|
||||
req.isUserAllowedToPost = async (): Promise<boolean> => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import type { H3Event } from 'h3';
|
||||
|
||||
import jwt from '~/server/jwt.ts';
|
||||
import { isGrantedForUser } from '~/src/helpers.ts';
|
||||
import type { User } from '~/src/user.ts';
|
||||
|
||||
export default async (event: H3Event) => {
|
||||
@ -17,5 +18,8 @@ export default async (event: H3Event) => {
|
||||
}
|
||||
|
||||
const user = rawUser?.authenticated ? rawUser : null;
|
||||
return { rawUser, user };
|
||||
const isGranted = (area: string = '', locale = global.config.locale): boolean => {
|
||||
return !!user && isGrantedForUser(user, locale, area);
|
||||
};
|
||||
return { rawUser, user, isGranted };
|
||||
};
|
||||
|
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 };
|
||||
};
|
@ -109,13 +109,13 @@ const deduplicateAdminMail = (projectDir: string, type: string, seconds: number)
|
||||
return true;
|
||||
};
|
||||
|
||||
interface Stats {
|
||||
export interface LocaleStats {
|
||||
locale: string;
|
||||
users: number;
|
||||
data: OverallStatsData | LocaleStatsData;
|
||||
}
|
||||
|
||||
interface OverallStatsData {
|
||||
export interface OverallStatsData {
|
||||
admins: number;
|
||||
userReports: number;
|
||||
bansPending: number;
|
||||
@ -125,7 +125,7 @@ interface OverallStatsData {
|
||||
linksQueue: number;
|
||||
}
|
||||
|
||||
interface LocaleStatsData {
|
||||
export interface LocaleStatsData {
|
||||
nouns: { approved: number; awaiting: number };
|
||||
inclusive: { approved: number; awaiting: number };
|
||||
terms: { approved: number; awaiting: number };
|
||||
@ -140,7 +140,7 @@ export const calculateStats = async (
|
||||
db: Database,
|
||||
allLocales: Record<string, LocaleDescription>,
|
||||
projectDir: string,
|
||||
): Promise<Stats[]> => {
|
||||
): Promise<LocaleStats[]> => {
|
||||
const id = ulid();
|
||||
|
||||
const heartbeat = await checkHeartbeat();
|
||||
|
@ -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