v3: added customizable site emoji (logo), small visual tweaks
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s

This commit is contained in:
2026-06-29 15:12:25 +02:00
parent aab5b5bfd5
commit f171027673
18 changed files with 342 additions and 78 deletions

View File

@@ -9,6 +9,11 @@
# Site name shown in the browser tab, app header, OG meta tags, and emails. # Site name shown in the browser tab, app header, OG meta tags, and emails.
GERBEUR_SITE_NAME=gerbeur GERBEUR_SITE_NAME=gerbeur
# Emoji the favicon set (SVG, .ico, PNG 16512, Apple touch icon) and the app
# header logo are generated/rendered from. Defaults to 🚚. Applied at runtime
# (server startup), so changing it only needs a restart — no rebuild.
GERBEUR_SITE_EMOJI=🚚
# Port the API server listens on (the container's internal port). # Port the API server listens on (the container's internal port).
GERBEUR_PORT=8000 GERBEUR_PORT=8000

7
.gitignore vendored
View File

@@ -161,6 +161,13 @@ dist
dist-ssr dist-ssr
*.local *.local
# Generated from GERBEUR_SITE_EMOJI (see api/scripts/generate-icons.ts)
/public/favicon.svg
/public/favicon.ico
/public/favicon-*.png
/public/apple-touch-icon.png
/public/manifest.webmanifest
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

View File

@@ -93,6 +93,15 @@ export const VALIDATION = {
// SEO/OG // SEO/OG
export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur"; export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
// Emoji the favicon set + app header logo are generated/rendered from.
// Applied at runtime (server startup + index.html injection), so changing it
// only needs a restart — no rebuild.
export const SITE_EMOJI = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚";
// Background color for generated icons and the manifest. Mirrors the
// hard-coded theme-color in index.html.
export const THEME_COLOR = "#111827";
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? ""; const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? "";
export const ALLOWED_ORIGINS: string[] = Array.from( export const ALLOWED_ORIGINS: string[] = Array.from(
new Set([ new Set([

View File

@@ -5,6 +5,7 @@ import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts";
import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts"; import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts";
import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts"; import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
import { up as up0006Categories } from "./migrations/0006_categories.ts"; import { up as up0006Categories } from "./migrations/0006_categories.ts";
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
interface Migration { interface Migration {
name: string; name: string;
@@ -21,6 +22,7 @@ const MIGRATIONS: Migration[] = [
{ name: "0004_user_roles", up: up0004UserRoles }, { name: "0004_user_roles", up: up0004UserRoles },
{ name: "0005_drop_is_admin", up: up0005DropIsAdmin }, { name: "0005_drop_is_admin", up: up0005DropIsAdmin },
{ name: "0006_categories", up: up0006Categories }, { name: "0006_categories", up: up0006Categories },
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
]; ];
export function runMigrations(db: DatabaseSync): void { export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,23 @@
import type { DatabaseSync } from "node:sqlite";
// Idempotent: safe to run against a fresh db (already created from schema.sql
// with password_reset_tokens) or an existing one that predates the table.
export function up(db: DatabaseSync): void {
const tables = new Set(
(db.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table';`,
).all() as { name: string }[]).map((r) => r.name),
);
if (!tables.has("password_reset_tokens")) {
db.exec(
`CREATE TABLE password_reset_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`,
);
}
}

161
api/lib/site-icons.ts Normal file
View File

@@ -0,0 +1,161 @@
// Generates the full favicon set + web manifest from a single emoji.
//
// Twemoji ships each emoji as a plain vector-path SVG, so it rasterizes without
// any system emoji font (which matters in the minimal Alpine runtime image).
// @resvg/resvg-wasm does the rasterization; the .ico is assembled by hand.
//
// Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in
// prod, public/ in dev) — see that script for how the output dir is chosen.
import { initWasm, Resvg } from "@resvg/resvg-wasm";
// Pinned Twemoji release.
const TWEMOJI_VERSION = "16.0.1";
const PNG_SIZES = [16, 32, 48, 64, 96, 128, 192, 256, 512];
const APPLE_SIZE = 180;
const ICO_SIZES = [16, 32, 48];
/**
* Mirror Twemoji's filename convention: strip variation selectors (FE0F) unless
* the emoji is a ZWJ sequence, then join codepoints with "-". Also doubles as a
* stable cache-busting version string for the icon URLs.
*/
export function emojiToCodepoint(emoji: string): string {
const cleaned = emoji.includes("") ? emoji : emoji.replace(//g, "");
return [...cleaned].map((ch) => ch.codePointAt(0)!.toString(16)).join("-");
}
let wasmReady: Promise<unknown> | undefined;
function ensureWasm(): Promise<unknown> {
if (!wasmReady) {
const wasmUrl = new URL(import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm"));
wasmReady = Deno.readFile(wasmUrl).then((bytes) => initWasm(bytes));
}
return wasmReady;
}
// Cache the fetched glyph per emoji so repeated runs don't re-hit the network.
const glyphCache = new Map<string, string>();
async function fetchGlyph(emoji: string): Promise<string> {
const cp = emojiToCodepoint(emoji);
const cached = glyphCache.get(cp);
if (cached) return cached;
const url =
`https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`;
let res: Response;
try {
res = await fetch(url);
} catch (cause) {
throw new Error(
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`,
{ cause },
);
}
if (!res.ok) {
throw new Error(
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}: ${res.status} ${res.statusText}`,
);
}
const svg = await res.text();
glyphCache.set(cp, svg);
return svg;
}
function renderPng(svg: string, size: number, background?: string): Uint8Array {
const resvg = new Resvg(svg, { fitTo: { mode: "width", value: size }, background });
return resvg.render().asPng();
}
// Pack PNG payloads into a (PNG-compressed) .ico container.
function buildIco(images: { size: number; png: Uint8Array }[]): Uint8Array {
const HEADER = 6;
const ENTRY = 16;
const offsetBase = HEADER + ENTRY * images.length;
const total = offsetBase + images.reduce((sum, i) => sum + i.png.length, 0);
const buf = new Uint8Array(total);
const dv = new DataView(buf.buffer);
dv.setUint16(2, 1, true); // type: icon
dv.setUint16(4, images.length, true);
let offset = offsetBase;
images.forEach((img, idx) => {
const e = HEADER + idx * ENTRY;
buf[e] = img.size >= 256 ? 0 : img.size; // width (0 means 256)
buf[e + 1] = img.size >= 256 ? 0 : img.size; // height
dv.setUint16(e + 4, 1, true); // color planes
dv.setUint16(e + 6, 32, true); // bits per pixel
dv.setUint32(e + 8, img.png.length, true); // size of image data
dv.setUint32(e + 12, offset, true); // offset of image data
buf.set(img.png, offset);
offset += img.png.length;
});
return buf;
}
function buildManifest(siteName: string, bgColor: string): string {
const manifest = {
name: siteName,
short_name: siteName,
start_url: "/",
display: "standalone",
background_color: bgColor,
theme_color: bgColor,
icons: [
{ src: "/favicon.svg", type: "image/svg+xml", sizes: "any" },
{ src: "/favicon-192x192.png", type: "image/png", sizes: "192x192" },
{
src: "/favicon-512x512.png",
type: "image/png",
sizes: "512x512",
purpose: "any maskable",
},
],
share_target: {
action: "/",
method: "GET",
params: { url: "share_url", title: "share_title", text: "share_text" },
},
};
return JSON.stringify(manifest, null, 2) + "\n";
}
export interface GenerateSiteIconsOptions {
/** Emoji to render the icons from. */
emoji: string;
/** Directory to write the assets into (created if missing). */
outDir: string;
/** Site name written into the manifest. */
siteName: string;
/** Background color for the Apple touch icon and manifest (iOS ignores transparency). */
bgColor: string;
}
/**
* Generate `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16512),
* `apple-touch-icon.png` and `manifest.webmanifest` into `outDir`.
* Throws if the emoji glyph can't be fetched.
*/
export async function generateSiteIcons(
opts: GenerateSiteIconsOptions,
): Promise<void> {
const { emoji, outDir, siteName, bgColor } = opts;
const svg = await fetchGlyph(emoji);
await ensureWasm();
await Deno.mkdir(outDir, { recursive: true });
const out = (name: string) => `${outDir}/${name}`;
await Deno.writeTextFile(out("favicon.svg"), svg);
const pngs = new Map<number, Uint8Array>();
for (const size of PNG_SIZES) pngs.set(size, renderPng(svg, size));
for (const size of PNG_SIZES) {
await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!);
}
// Apple touch icon gets a solid background — iOS doesn't honor transparency.
await Deno.writeFile(out("apple-touch-icon.png"), renderPng(svg, APPLE_SIZE, bgColor));
await Deno.writeFile(
out("favicon.ico"),
buildIco(ICO_SIZES.map((size) => ({ size, png: pngs.get(size)! }))),
);
await Deno.writeTextFile(out("manifest.webmanifest"), buildManifest(siteName, bgColor));
}

View File

@@ -1,5 +1,8 @@
import { Context, Next, send } from "@oak/oak"; import { Context, Next, send } from "@oak/oak";
import { OG_SITE_NAME } from "../config.ts"; import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { emojiToCodepoint } from "./site-icons.ts";
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
async function serveIndexHtml( async function serveIndexHtml(
context: Context<Record<string, object>>, context: Context<Record<string, object>>,
@@ -7,7 +10,10 @@ async function serveIndexHtml(
) { ) {
const filePath = `${root}/index.html`; const filePath = `${root}/index.html`;
const raw = await Deno.readTextFile(filePath); const raw = await Deno.readTextFile(filePath);
const html = raw.replaceAll("__SITE_NAME__", OG_SITE_NAME); const html = raw
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION);
context.response.type = "text/html"; context.response.type = "text/html";
context.response.body = html; context.response.body = html;
} }

View File

@@ -2,7 +2,10 @@ import { Context, Next } from "@oak/oak";
import { getDump } from "../services/dump-service.ts"; import { getDump } from "../services/dump-service.ts";
import { getUserByUsername } from "../services/user-service.ts"; import { getUserByUsername } from "../services/user-service.ts";
import { getPlaylistById } from "../services/playlist-service.ts"; import { getPlaylistById } from "../services/playlist-service.ts";
import { OG_SITE_NAME } from "../config.ts"; import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { emojiToCodepoint } from "../lib/site-icons.ts";
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
interface OGMeta { interface OGMeta {
title: string; title: string;
@@ -59,10 +62,10 @@ async function loadIndexHtml(): Promise<string | null> {
const path of [`${Deno.cwd()}/dist/index.html`, `${Deno.cwd()}/index.html`] const path of [`${Deno.cwd()}/dist/index.html`, `${Deno.cwd()}/index.html`]
) { ) {
try { try {
cachedHtml = (await Deno.readTextFile(path)).replaceAll( cachedHtml = (await Deno.readTextFile(path))
"__SITE_NAME__", .replaceAll("__SITE_NAME__", OG_SITE_NAME)
OG_SITE_NAME, .replaceAll("__SITE_EMOJI__", SITE_EMOJI)
); .replaceAll("__ICON_VERSION__", ICON_VERSION);
return cachedHtml; return cachedHtml;
} catch { } catch {
continue; continue;

View File

@@ -0,0 +1,27 @@
// Generates the favicon set + manifest from the runtime GERBEUR_SITE_EMOJI.
// Runs before the server starts, so changing the emoji only requires a restart
// — no rebuild.
//
// The output directory is passed as the first argument (relative to cwd):
// - prod (`deno task start` / `serve`): `dist` — what the Oak server serves
// - dev (`deno task dev`): `public` — what the Vite server serves
// Defaults to `dist` when omitted.
import { OG_SITE_NAME, SITE_EMOJI, THEME_COLOR } from "../config.ts";
import { generateSiteIcons } from "../lib/site-icons.ts";
const outDir = `${Deno.cwd()}/${Deno.args[0] ?? "dist"}`;
try {
await Deno.stat(outDir);
} catch {
console.log(`[icons] ${outDir} not found — skipping favicon generation.`);
Deno.exit(0);
}
await generateSiteIcons({
emoji: SITE_EMOJI,
outDir,
siteName: OG_SITE_NAME,
bgColor: THEME_COLOR,
});
console.log(`[icons] Generated favicons for "${SITE_EMOJI}" into ${outDir}`);

View File

@@ -1,12 +1,13 @@
{ {
"tasks": { "tasks": {
"dev": "deno run --env-file -A npm:vite & deno task server:start", "dev": "deno task icons public && deno run --env-file -A npm:vite & deno task server:start",
"build": "deno task i18n:extract && deno task i18n:compile && deno run --env-file -A npm:vite build", "build": "deno task i18n:extract && deno task i18n:compile && deno run --env-file -A npm:vite build",
"server:start": "deno run --env-file -A --watch api/main.ts", "server:start": "deno run --env-file -A --watch api/main.ts",
"serve": "deno task build && deno task server:start", "icons": "deno run --env-file -A api/scripts/generate-icons.ts",
"serve": "deno task build && deno task icons dist && deno task server:start",
"i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1", "i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1",
"i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1", "i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1",
"start": "deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts" "start": "deno task icons dist && deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts"
}, },
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"compilerOptions": { "compilerOptions": {
@@ -29,7 +30,8 @@
"@panva/jose": "jsr:@panva/jose@^6.2.3", "@panva/jose": "jsr:@panva/jose@^6.2.3",
"@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1", "@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1",
"nodemailer": "npm:nodemailer@^9.0.1", "nodemailer": "npm:nodemailer@^9.0.1",
"marked": "npm:marked@^18.0.5" "marked": "npm:marked@^18.0.5",
"@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@^2.6.2"
}, },
"allowScripts": ["npm:@swc/core@1.15.41", "npm:esbuild@0.25.12"] "allowScripts": ["npm:@swc/core@1.15.41", "npm:esbuild@0.25.12"]
} }

6
deno.lock generated
View File

@@ -29,6 +29,8 @@
"npm:@lingui/react@6.4.0": "6.4.0_react@19.2.7", "npm:@lingui/react@6.4.0": "6.4.0_react@19.2.7",
"npm:@lingui/swc-plugin@6.5.0": "6.5.0_@lingui+core@6.4.0", "npm:@lingui/swc-plugin@6.5.0": "6.5.0_@lingui+core@6.4.0",
"npm:@lingui/vite-plugin@6.4.0": "6.4.0_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0", "npm:@lingui/vite-plugin@6.4.0": "6.4.0_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0",
"npm:@resvg/resvg-wasm@2.6.2": "2.6.2",
"npm:@resvg/resvg-wasm@^2.6.2": "2.6.2",
"npm:@types/node@^26.0.1": "26.0.1", "npm:@types/node@^26.0.1": "26.0.1",
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.17", "npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.17",
"npm:@types/react@^19.2.17": "19.2.17", "npm:@types/react@^19.2.17": "19.2.17",
@@ -656,6 +658,9 @@
"@oxc-project/types@0.137.0": { "@oxc-project/types@0.137.0": {
"integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==" "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="
}, },
"@resvg/resvg-wasm@2.6.2": {
"integrity": "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="
},
"@rolldown/binding-android-arm64@1.1.3": { "@rolldown/binding-android-arm64@1.1.3": {
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
"os": ["android"], "os": ["android"],
@@ -2650,6 +2655,7 @@
"jsr:@oak/oak@^17.2.0", "jsr:@oak/oak@^17.2.0",
"jsr:@panva/jose@^6.2.3", "jsr:@panva/jose@^6.2.3",
"jsr:@tajpouria/cors@^1.2.1", "jsr:@tajpouria/cors@^1.2.1",
"npm:@resvg/resvg-wasm@^2.6.2",
"npm:marked@^18.0.5", "npm:marked@^18.0.5",
"npm:nodemailer@^9.0.1" "npm:nodemailer@^9.0.1"
], ],

View File

@@ -2,15 +2,21 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg?v=__ICON_VERSION__" />
<link rel="icon" href="/favicon.ico?v=__ICON_VERSION__" sizes="any" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=__ICON_VERSION__" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=__ICON_VERSION__" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png?v=__ICON_VERSION__" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png?v=__ICON_VERSION__" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Saira:ital,wght@0,100..900;1,100..900&family=Space+Grotesk:wght@400;700;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Saira:ital,wght@0,100..900;1,100..900&family=Space+Grotesk:wght@400;700;900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111827" /> <meta name="theme-color" content="#111827" />
<meta name="site-name" content="__SITE_NAME__" /> <meta name="site-name" content="__SITE_NAME__" />
<link rel="manifest" href="/manifest.webmanifest" /> <meta name="site-emoji" content="__SITE_EMOJI__" />
<link rel="apple-touch-icon" href="/favicon.svg" /> <link rel="manifest" href="/manifest.webmanifest?v=__ICON_VERSION__" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=__ICON_VERSION__" />
<title>__SITE_NAME__</title> <title>__SITE_NAME__</title>
</head> </head>
<body> <body>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 551 B

View File

@@ -1,6 +1,6 @@
{ {
"name": "gerbeur", "name": "coucou",
"short_name": "gerbeur", "short_name": "coucou",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#111827", "background_color": "#111827",
@@ -10,6 +10,17 @@
"src": "/favicon.svg", "src": "/favicon.svg",
"type": "image/svg+xml", "type": "image/svg+xml",
"sizes": "any" "sizes": "any"
},
{
"src": "/favicon-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/favicon-512x512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
} }
], ],
"share_target": { "share_target": {

View File

@@ -2868,6 +2868,12 @@ body.has-fab .page-content {
align-self: center; align-self: center;
} }
/* Slightly smaller 16:9 thumb on phones so the body keeps enough width. */
.dump-card-preview {
width: 104px;
height: 58px;
}
.dump-card-meta { .dump-card-meta {
flex-direction: column; flex-direction: column;
gap: 0.1rem; gap: 0.1rem;
@@ -2886,21 +2892,35 @@ body.has-fab .page-content {
.dump-card-preview, .dump-card-preview,
.playlist-card-preview { .playlist-card-preview {
flex-shrink: 0; flex-shrink: 0;
width: 80px;
height: 80px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
background: var(--color-bg); background: var(--color-bg);
/* Subtle matte so any unfilled space (icons, off-ratio art) reads as
intentional framing rather than a broken bar. */
box-shadow: inset 0 0 0 1px var(--color-border);
transition: transform 0.18s ease, box-shadow 0.18s ease; transition: transform 0.18s ease, box-shadow 0.18s ease;
} }
/* Feed thumbnails are mostly 16:9 (YouTube, og:image) — match that ratio so
object-fit: cover crops almost nothing, and go wider for legibility. */
.dump-card-preview {
width: 128px;
height: 72px;
}
/* Playlist art is square (album covers) — keep it square. */
.playlist-card-preview {
width: 80px;
height: 80px;
}
.dump-card-inner:hover .dump-card-preview, .dump-card-inner:hover .dump-card-preview,
.playlist-card-inner:hover .playlist-card-preview { .playlist-card-inner:hover .playlist-card-preview {
transform: scale(1.08); transform: scale(1.08);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); box-shadow: inset 0 0 0 1px var(--color-border), 0 4px 12px rgba(0, 0, 0, 0.25);
} }
/* ── Shared card preview icon ── */ /* ── Shared card preview icon ── */
@@ -2920,15 +2940,15 @@ body.has-fab .page-content {
} }
.dump-card-preview .rich-content-compact { .dump-card-preview .rich-content-compact {
width: 80px; width: 100%;
height: 80px; height: 100%;
justify-content: center; justify-content: center;
} }
.dump-card-preview .rich-content-compact-thumbnail { .dump-card-preview .rich-content-compact-thumbnail {
width: 80px; width: 100%;
height: 80px; height: 100%;
object-fit: contain; object-fit: cover;
border-radius: 0; border-radius: 0;
border: none; border: none;
} }

View File

@@ -7,6 +7,7 @@ import { useWS } from "../hooks/useWS.ts";
import { NotificationBell } from "./NotificationBell.tsx"; import { NotificationBell } from "./NotificationBell.tsx";
import { UserMenu } from "./UserMenu.tsx"; import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx"; import { SearchBar } from "./SearchBar.tsx";
import { SITE_EMOJI, SITE_NAME } from "../hooks/useDocumentTitle.ts";
const DumpCreateModal = lazy(() => const DumpCreateModal = lazy(() =>
import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal })) import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal }))
@@ -43,10 +44,9 @@ export function AppHeader(
<> <>
<header className="app-header app-header--has-center"> <header className="app-header app-header--has-center">
<Link to="/" className="app-header-brand"> <Link to="/" className="app-header-brand">
🚚<span className="app-header-brand-name"> {SITE_EMOJI}<span className="app-header-brand-name">
{" "} {" "}
{document.querySelector<HTMLMetaElement>('meta[name="site-name"]') {SITE_NAME}
?.content ?? "gerbeur"}
</span> </span>
</Link> </Link>

View File

@@ -12,6 +12,17 @@ function readSiteName(): string {
export const SITE_NAME = readSiteName(); export const SITE_NAME = readSiteName();
// The server injects the configured emoji into a <meta name="site-emoji"> tag.
// In the Vite dev server it's substituted too; fall back to the default truck.
function readSiteEmoji(): string {
const meta = document.querySelector('meta[name="site-emoji"]')
?.getAttribute("content")?.trim();
if (meta && meta !== "__SITE_EMOJI__") return meta;
return "🚚";
}
export const SITE_EMOJI = readSiteEmoji();
/** /**
* Keeps document.title in sync with the current page during client-side * Keeps document.title in sync with the current page during client-side
* navigation. Without this the tab title only ever reflects the server-rendered * navigation. Without this the tab title only ever reflects the server-rendered

View File

@@ -1,56 +1,19 @@
import { defineConfig, type Plugin } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import { lingui } from "@lingui/vite-plugin"; import { lingui } from "@lingui/vite-plugin";
import fs from "node:fs";
import path from "node:path";
const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur"; const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur";
const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚";
function manifestPlugin(): Plugin { // Cache-busting token for the favicon URLs (see index.html). Changes when the
const cssPath = path.resolve("src/index.css"); // emoji changes, so browsers don't keep serving a stale cached icon. Mirrors
const outPath = path.resolve("public/manifest.webmanifest"); // Twemoji's filename convention (kept trivial here to avoid the frontend build
// depending on backend code; the canonical version lives in api/lib/site-icons.ts).
function cssVar(rootBlock: string, name: string): string | undefined { function emojiToCodepoint(emoji: string): string {
return rootBlock.match( const cleaned = emoji.includes("") ? emoji : emoji.replace(//g, "");
new RegExp(`${name.replace("-", "\\-")}:\\s*(#[0-9a-fA-F]{3,8})`), return [...cleaned].map((ch) => ch.codePointAt(0)!.toString(16)).join("-");
)?.[1];
}
function generate() {
const css = fs.readFileSync(cssPath, "utf-8");
// Only read the first :root block — dark-mode defaults, before any @media overrides
const rootBlock = css.match(/:root\s*\{([^}]+)\}/)?.[1] ?? "";
const bgColor = cssVar(rootBlock, "--color-bg") ?? "#111827";
const manifest = {
name: SITE_NAME,
short_name: SITE_NAME,
start_url: "/",
display: "standalone",
background_color: bgColor,
theme_color: bgColor,
icons: [{ src: "/favicon.svg", type: "image/svg+xml", sizes: "any" }],
share_target: {
action: "/",
method: "GET",
params: { url: "share_url", title: "share_title", text: "share_text" },
},
};
fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + "\n");
}
return {
name: "generate-manifest",
buildStart: generate,
configureServer(server) {
generate();
server.watcher.on("change", (file) => {
if (path.resolve(file) === cssPath) generate();
});
},
};
} }
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
export default defineConfig({ export default defineConfig({
server: { server: {
@@ -74,16 +37,18 @@ export default defineConfig({
}, },
}, },
plugins: [ plugins: [
manifestPlugin(),
{ {
// In dev the API server isn't serving index.html, so replace the placeholder here. // In dev the API server isn't serving index.html, so replace the placeholders here.
// In production the Oak server does the replacement at runtime (see api/lib/static.ts). // In production the Oak server does the replacement at runtime (see api/lib/static.ts).
name: "inject-site-name", name: "inject-site-name",
transformIndexHtml: { transformIndexHtml: {
order: "pre", order: "pre",
handler: (html, ctx) => handler: (html, ctx) =>
ctx.server ctx.server
? html.replaceAll("__SITE_NAME__", SITE_NAME) ? html
.replaceAll("__SITE_NAME__", SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION)
: html, : html,
}, },
}, },