Merge branch 'fix-calendar-banner-p' into 'main'

fix calendar banner

See merge request PronounsPage/PronounsPage!588
This commit is contained in:
Valentyne Stigloher 2025-03-08 14:13:27 +00:00
commit 977d6bd652
11 changed files with 278 additions and 114 deletions

View File

@ -38,11 +38,21 @@
</a>
</p>
<p v-else class="mb-0">
<a :href="`/calendar/${year.year}-overview.png`" target="_blank" rel="noopener" class="btn btn-outline-primary m-1">
<a
:href="`/api/banner/${encodeURIComponent(config.calendar.route)}/${year.year}.png`"
target="_blank"
rel="noopener"
class="btn btn-outline-primary m-1"
>
<Icon v="table" />
<T>calendar.view.grid</T>
</a>
<a :href="`/calendar/${year.year}-labels.png`" target="_blank" rel="noopener" class="btn btn-outline-primary m-1">
<a
:href="`/api/banner/${encodeURIComponent(config.calendar.route)}/${year.year}-labels.png`"
target="_blank"
rel="noopener"
class="btn btn-outline-primary m-1"
>
<Icon v="list" />
<T>calendar.view.list</T>
</a>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import { formatSize } from '~/src/helpers.ts';
defineProps<{
chunk: string;
node: { size: number; atime: string; mtime: string };
}>();
</script>
<template>
<div class="py-1 ps-3 d-flex justify-content-between">
<code>{{ chunk }}</code>
<span>
<span class="badge text-bg-info">{{ formatSize(node.size) }}</span>
<span class="badge text-bg-light">{{ node.atime }}</span>
</span>
</div>
</template>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { formatSize } from '~/src/helpers.ts';
type Tree = Map<string, Tree | { size: number; atime: string; mtime: string }>;
defineProps<{
chunk: string;
node: Tree;
}>();
const sumSize = (tree: Tree): number => {
return [...tree.values()].map((node) => {
if (node instanceof Map) {
return sumSize(node);
}
return node.size;
})
.reduce((sum, size) => sum + size, 0);
};
const minDate = (tree: Tree): Date => {
return [...tree.values()].map((node) => {
if (node instanceof Map) {
return minDate(node);
}
return new Date(node.atime);
})
.reduce((minDate, date) => minDate.getTime() <= date.getTime() ? minDate : date, new Date());
};
</script>
<template>
<details>
<summary>
<span class="py-1 d-inline-flex justify-content-between">
<code>{{ chunk }}</code>
<span>
<span class="badge text-bg-info">{{ formatSize(sumSize(node)) }}</span>
<span class="badge text-bg-light">{{ minDate(node).toISOString() }}</span>
</span>
</span>
</summary>
<div class="ps-2 border-start border-secondary">
<template v-for="[childChunk, childNode] of node.entries()" :key="childNode">
<AdminStorageTree v-if="childNode instanceof Map" :chunk="childChunk" :node="childNode" />
<AdminStorageItem v-else :chunk="childChunk" :node="childNode" />
</template>
</div>
</details>
</template>
<style scoped lang="scss">
@import 'assets/variables';
summary > span {
width: calc(100% - #{$spacer});
}
</style>

55
pages/admin/storage.vue Normal file
View File

@ -0,0 +1,55 @@
<script setup lang="ts">
import AdminStorageTree from '~/components/admin/AdminStorageTree.vue';
const metasAsyncData = useFetch('/api/admin/storage/metas', { lazy: true });
const tree = computed(() => {
const tree = new Map();
for (const meta of metasAsyncData.data.value ?? []) {
const path = meta.key.split(':');
let subtree = tree;
for (const chunk of path.slice(0, path.length - 1)) {
if (!subtree.has(chunk)) {
subtree.set(chunk, new Map());
}
subtree = subtree.get(chunk);
}
subtree.set(path[path.length - 1], meta);
}
return tree;
});
</script>
<template>
<Page>
<NotFound v-if="!$isGranted('code')" />
<template v-else>
<p>
<nuxt-link to="/admin">
<Icon v="user-cog" />
<T>admin.header</T>
</nuxt-link>
</p>
<h2>
<Icon v="layer-group" />
Storage
</h2>
<p>
Gives an overview about mounted storages.
</p>
<button type="button" class="btn btn-outline-secondary" @click="metasAsyncData.execute()">
<Icon v="sync" />
Refresh
</button>
<Loading :value="metasAsyncData.data.value">
<template v-for="[chunk, node] of tree.entries()" :key="chunk">
<AdminStorageTree v-if="node instanceof Map" :chunk :node />
<AdminStorageItem v-else :chunk :node />
</template>
</Loading>
</template>
</Page>
</template>

View File

@ -21,6 +21,9 @@ definePageMeta({
const { $translator: translator } = useNuxtApp();
const route = useRoute();
const config = useConfig();
if (!config.calendar?.enabled) {
throw new Error('config.calendar is disabled');
}
const calendar = await loadCalendar();
const year = route.params.year
@ -30,7 +33,7 @@ const year = route.params.year
if (year) {
useSimpleHead({
title: translator.translate('calendar.headerLong'),
banner: `calendar/${year.year}-overview.png`,
banner: `/api/${encodeURIComponent(config.calendar.route)}/${year.year}.png`,
}, translator);
}

View File

@ -20,6 +20,11 @@ definePageMeta({
const { $translator: translator } = useNuxtApp();
const route = useRoute();
const config = useConfig();
if (!config.calendar?.enabled) {
throw new Error('config.calendar is disabled');
}
const day = new Day(
parseInt(route.params.year as string),
@ -29,10 +34,9 @@ const day = new Day(
useSimpleHead({
title: translator.translate('calendar.headerLong'),
banner: `calendar/${day}.png`,
banner: `/api/${encodeURIComponent(config.calendar.route)}/${day}.png`,
}, translator);
const config = useConfig();
const year = (await loadCalendar()).getYear(day.year);
const basic = route.query.layout === 'basic';

View File

@ -0,0 +1,22 @@
export default defineEventHandler(async (event) => {
const { isGranted } = await useAuthentication(event);
if (!isGranted('code')) {
throw createError({
status: 401,
statusMessage: 'Unauthorised',
});
}
const keys = (await useStorage().getKeys())
.filter((key) => !key.startsWith('build') && !key.startsWith('root') && !key.startsWith('src'))
.toSorted();
return await Promise.all(keys.map(async (key) => {
const meta = await useStorage().getMeta(key);
return {
key,
size: meta.size,
atime: meta.atime,
mtime: meta.mtime,
};
}));
});

View File

@ -1,20 +1,19 @@
import fs from 'node:fs/promises';
import { createCanvas, loadImage } from 'canvas';
import type { CanvasRenderingContext2D, Image } from 'canvas';
import { Router } from 'express';
import type { Canvas, CanvasRenderingContext2D, Image } from 'canvas';
import Pageres from 'pageres';
import sharp from 'sharp';
import SQL from 'sql-template-strings';
import type { Translations } from '../../locale/translations.ts';
import { buildPronounUsage } from '../../src/buildPronoun.ts';
import type { PronounUsage } from '../../src/classes.ts';
import { handleErrorAsync } from '../../src/helpers.ts';
import { Translator } from '../../src/translator.ts';
import type { User } from '../../src/user.ts';
import avatar from '../avatar.ts';
import { loadSuml, loadSumlFromBase } from '../loader.ts';
import { registerLocaleFont } from '../localeFont.ts';
import type { Translations } from '../../../locale/translations.ts';
import { buildPronounUsage } from '../../../src/buildPronoun.ts';
import type { PronounUsage } from '../../../src/classes.ts';
import { Translator } from '../../../src/translator.ts';
import type { User } from '../../../src/user.ts';
import avatar from '../../avatar.ts';
import { loadSuml, loadSumlFromBase } from '../../loader.ts';
import { registerLocaleFont } from '../../localeFont.ts';
import { pronounLibrary } from '~/server/data.ts';
import type { Database } from '~/server/db.ts';
@ -46,8 +45,6 @@ const loadSvgImage = async (svg: string): Promise<Image> => {
return await loadImage(pngBuffer);
};
const router = Router();
const width = 1200;
const height = 600;
const mime = 'image/png';
@ -55,6 +52,38 @@ const imageSize = 200;
const leftRatio = 4;
const getBannerKey = defineCachedFunction(async (path: string, db: Database) => {
let result = undefined;
if (path === 'zaimki') {
result = await drawDefault();
}
if (path.startsWith('@')) {
const user = await db.get<Pick<User, 'id' | 'username' | 'email' | 'avatarSource'>>(SQL`SELECT id, username, email, avatarSource FROM users WHERE username=${path.substring(1)}`);
if (user) {
result = await drawProfileBanner(user, db);
}
}
if (config.calendar?.enabled && path.startsWith(config.calendar.route)) {
result = await drawCalendar(path.substring(config.calendar.route.length + 1));
}
if (!result) {
const usage = buildPronounUsage(pronounLibrary, path, config, translator);
if (usage !== null) {
result = await drawPronounUsage(usage);
}
}
if (!result) {
result = await drawDefault();
}
await useStorage('data').setItemRaw<Buffer>(`banner:${result.key}`, result.buffer);
return result.key;
}, {
name: 'banner',
getKey: (path) => path,
maxAge: 24 * 60 * 60,
});
const createBaseCanvas = async (): Promise<{ canvas: Canvas; context: CanvasRenderingContext2D; fontName: string }> => {
const fontName = registerLocaleFont(global.config, 'fontHeadings', ['regular', 'bold']);
const canvas = createCanvas(width, height);
@ -62,46 +91,22 @@ const getBannerKey = defineCachedFunction(async (path: string, db: Database) =>
const bg = await loadImage('public/bg.png');
context.drawImage(bg, 0, 0, width, height);
return { canvas, context, fontName };
};
let key = undefined;
if (path === 'zaimki') {
key = await drawDefault(context, fontName);
}
if (!key && path.startsWith('@')) {
const user = await db.get<Pick<User, 'id' | 'username' | 'email' | 'avatarSource'>>(SQL`SELECT id, username, email, avatarSource FROM users WHERE username=${path.substring(1)}`);
if (user) {
key = await drawProfileBanner(context, fontName, user, db);
}
}
if (!key) {
const usage = buildPronounUsage(pronounLibrary, path, config, translator);
if (usage !== null) {
key = await drawPronounUsage(context, fontName, usage);
}
}
if (!key) {
key = await drawDefault(context, fontName);
}
await useStorage('data').setItemRaw<Buffer>(`banner:${key}`, canvas.toBuffer(mime));
return key;
}, {
name: 'banner',
getKey: (path) => path,
maxAge: 24 * 60 * 60,
});
const drawDefault = async (context: CanvasRenderingContext2D, fontName: string) => {
const drawDefault = async () => {
const { canvas, context, fontName } = await createBaseCanvas();
const leftRatio = 5;
const logo = await loadSvgImage('public/logo/logo.svg');
context.drawImage(logo, width / leftRatio - imageSize / 2, height / 2 - imageSize / 2, imageSize, imageSize);
context.font = `${translations.title.length < 10 ? 120 : translations.title.length < 14 ? 80 : 72}pt '${fontName}'`;
context.fillText(translations.title, width / leftRatio + imageSize / 1.5, height / 2 + (translations.title.length < 10 ? 48 : translations.title.length < 14 ? 36 : 24));
return 'default.png';
return { key: 'default.png', buffer: canvas.toBuffer(mime) };
};
const drawPronounUsage = async (context: CanvasRenderingContext2D, fontName: string, usage: PronounUsage) => {
const drawPronounUsage = async (usage: PronounUsage) => {
const { canvas, context, fontName } = await createBaseCanvas();
const logo = await loadSvgImage('public/logo/logo.svg');
context.drawImage(logo, width / leftRatio - imageSize / 2, height / 2 - imageSize / 2, imageSize, imageSize);
@ -114,15 +119,14 @@ const drawPronounUsage = async (context: CanvasRenderingContext2D, fontName: str
width / leftRatio + imageSize / 1.5,
height / 2 + (usage.short.options.length <= 2 ? 72 : 24),
);
return `pronoun:${usage.short.options.join('&').replaceAll(/[/:]/g, '-')}.png`;
return {
key: `pronoun:${usage.short.options.join('&').replaceAll(/[/:]/g, '-')}.png`,
buffer: canvas.toBuffer(mime),
};
};
const drawProfileBanner = async (
context: CanvasRenderingContext2D,
fontName: string,
user: Pick<User, 'id' | 'username' | 'email' | 'avatarSource'>,
db: Database,
) => {
const drawProfileBanner = async (user: Pick<User, 'id' | 'username' | 'email' | 'avatarSource'>, db: Database) => {
const { canvas, context, fontName } = await createBaseCanvas();
const logoPrimary = await loadSvgImage('public/logo/logo-primary.svg');
try {
@ -139,15 +143,45 @@ const drawProfileBanner = async (
context.drawImage(logoPrimary, width / leftRatio + imageSize, height / 2 + logoSize - 8, logoSize, logoSize);
context.fillText(translations.title, width / leftRatio + imageSize + 36, height / 2 + 48);
return `user:${user.username}.png`;
return { key: `user:${user.username}.png`, buffer: canvas.toBuffer(mime) };
};
router.get('/banner/:pronounName*.png', handleErrorAsync(async (req, res) => {
const path = (req.params.pronounName + req.params[0]).replace(/(&amp)+$/, '');
const shoot = async (url: string) => {
const pr = new Pageres({
delay: 3,
scale: 2,
launchOptions: {
headless: 'new',
},
});
pr.source(process.env.NUXT_PUBLIC_BASE_URL + url, ['1500x300']);
const screenshots = await pr.run();
return new Buffer(screenshots[0]);
};
const key = await getBannerKey(path, req.db);
const drawCalendar = async (date: string) => {
if (!config.calendar?.enabled) {
throw new Error('config.calendar is disabled');
}
let url;
const match = date.match(/(\d{4})-labels/);
if (match) {
const year = match[1];
url = `/${config.calendar.route}/${year}?layout=basic&labels=true`;
} else {
url = `/${config.calendar.route}/${date}?layout=basic`;
}
return { key: `calendar:${date}.png`, buffer: await shoot(url) };
};
export default defineEventHandler(async (event) => {
const path = (getRouterParam(event, 'path', { decode: true }) ?? '').replace(/(&amp)+$/, '').replace(/\.png$/, '');
const db = useDatabase();
const key = await getBannerKey(path, db);
const banner = await useStorage('data').getItemRaw<Buffer>(`banner:${key}`);
return res.set('content-type', mime).send(banner);
}));
export default router;
appendHeader(event, 'content-type', mime);
return banner;
});

View File

@ -2,37 +2,16 @@ import './setup.ts';
import fs from 'fs';
import Pageres from 'pageres';
import type { Config } from '../locale/config.ts';
import dbConnection from './db.ts';
import { loadSuml } from '~/server/loader.ts';
import { buildCalendar } from '~/src/calendar/calendar.ts';
const __dirname = new URL('.', import.meta.url).pathname;
const config = loadSuml('config') as Config;
const dir = `${__dirname}/../public/calendar`;
const force = process.argv[2] === '-f' || process.argv[2] === '--force';
const shoot = async (url: string, filename: string): Promise<void> => {
const pr = new Pageres({
delay: 3,
scale: 2,
});
pr.source(process.env.NUXT_PUBLIC_BASE_URL + url, ['1500x300']);
for (const buffer of await pr.run()) {
fs.mkdirSync(dir, { recursive: true });
const target = `${dir}/${filename}.png`;
console.log(target);
fs.writeFileSync(target, buffer);
}
};
const dumpNameDays = async (): Promise<void> => {
if (!config.names || !config.names.enabled || !config.names.namedays) {
return;
@ -57,32 +36,5 @@ const dumpNameDays = async (): Promise<void> => {
return;
}
const prevPath = `${__dirname}/../cache/calendar.json`;
const prev = fs.existsSync(prevPath) ? JSON.parse(fs.readFileSync(prevPath, 'utf-8')) : {};
const localEvents = (await import(`../locale/${config.locale}/calendar/events.ts`)).default;
const current = buildCalendar(localEvents, process.env.NUXT_PUBLIC_BASE_URL!).buildSummary();
const changedYears = new Set();
for (const day in current) {
if (!Object.hasOwn(current, day)) {
continue;
}
const year = day.substring(0, 4);
if (current[day] !== prev[day] || !fs.existsSync(`${dir}/${day}.png`) || force) {
await shoot(`/${config.calendar.route}/${day}?layout=basic`, `${day}`);
changedYears.add(year);
}
if (!fs.existsSync(`${dir}/${year}-overview.png`) || !fs.existsSync(`${dir}/${year}-labels.png`) || force) {
changedYears.add(year);
}
}
for (const year of changedYears) {
await shoot(`/${config.calendar.route}/${year}?layout=basic`, `${year}-overview`);
await shoot(`/${config.calendar.route}/${year}?layout=basic&labels=true`, `${year}-labels`);
}
fs.writeFileSync(prevPath, JSON.stringify(current, null, 4));
await dumpNameDays();
})();

View File

@ -18,7 +18,6 @@ import './globals.ts';
import dbConnection from './db.ts';
import type { Database, SQLQuery } from './db.ts';
import adminRoute from './express/admin.ts';
import bannerRoute from './express/banner.ts';
import calendarRoute from './express/calendar.ts';
import censusRoute from './express/census.ts';
import discord from './express/discord.ts';
@ -145,7 +144,6 @@ router.use(grantOverridesRoute);
router.use(grant.express()(config));
router.use(homeRoute);
router.use(bannerRoute);
router.use(sentryRoute);
if (!global.config.macrolanguage?.enabled) {

View File

@ -563,6 +563,16 @@ export const filterObjectKeys = <T extends Record<string, any>, K extends keyof
}, {} as Pick<T, K>);
};
export const formatSize = (number: number): string => {
if (number > 1000000) {
return `${Math.round(10 * number / 1000000) / 10}\u00a0MB`;
}
if (number > 1000) {
return `${Math.round(10 * number / 1000) / 10}\u00a0kB`;
}
return number.toString();
};
export const executeUnlessPrerendering = (fn: () => void): (() => void) => {
return () => {
if ((document as any).prerendering) {