mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-23 12:43:48 -04:00
Merge branch 'fix-calendar-banner-p' into 'main'
fix calendar banner See merge request PronounsPage/PronounsPage!588
This commit is contained in:
commit
977d6bd652
@ -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>
|
||||
|
18
components/admin/AdminStorageItem.vue
Normal file
18
components/admin/AdminStorageItem.vue
Normal 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>
|
58
components/admin/AdminStorageTree.vue
Normal file
58
components/admin/AdminStorageTree.vue
Normal 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
55
pages/admin/storage.vue
Normal 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>
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
22
server/api/admin/storage/metas.get.ts
Normal file
22
server/api/admin/storage/metas.get.ts
Normal 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,
|
||||
};
|
||||
}));
|
||||
});
|
@ -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(/(&)+$/, '');
|
||||
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(/(&)+$/, '').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;
|
||||
});
|
@ -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();
|
||||
})();
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user