v3: generated placeholder thumbnails for pages with no preview image, and a fix for hotlink-protected ones
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Thumbnails were failing in two different ways that both ended as an empty box. Cloudflare hotlink protection answers a cross-site Referer with 403, so images we had extracted correctly (dles.aukspot.com's og:image among them) never rendered — every onError handler set display:none and swallowed it. The new Thumbnail component loads with referrerPolicy="no-referrer", retries once through /api/proxy-image for hosts that reject an empty referrer too, and only then falls back to a placeholder. Separately, the extraction cascade ended at the page's icon and then a guessed /favicon.ico, so thumbnailUrl was almost never empty — just a 16x16 icon cover-cropped into a 128x72 box. It now stops at real artwork, with faviconUrl and accentColor (theme-color / msapplication-TileColor / mask-icon) as their own fields. An absent thumbnailUrl finally means "no artwork", which is what makes the placeholder possible: the site's own color mixed into the theme surface, with its favicon centered on it, or its initial. Contrast holds for any third-party color by construction rather than by luminance math, so nyt only has to set --thumb-tint-strength to 0% to stay monochrome and geocities only has to raise it. Missing accents fall back to a stable hostname-derived hue, so rows saved before this get a tint with no backfill. Migration 0011 reclassifies favicon-shaped thumbnailUrls on existing dumps. The journal mosaic keeps its pull-quote and text fallbacks — the placeholder appears there only to repair a broken image. Also fixed: refresh silently overwrote good metadata with a failure stub, the refresh button swallowed every error, refresh never broadcast the update, extractBestIcon ranked SVG icons below 16x16 PNGs, and shared links with no artwork carried no og:image at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { up as up0007PasswordResetTokens } from "./migrations/0007_password_rese
|
|||||||
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
|
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
|
||||||
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
|
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
|
||||||
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
|
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
|
||||||
|
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,6 +30,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
||||||
{ name: "0009_chat_reply", up: up0009ChatReply },
|
{ name: "0009_chat_reply", up: up0009ChatReply },
|
||||||
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
|
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
|
||||||
|
{ name: "0011_split_favicon_thumbnail", up: up0011SplitFaviconThumbnail },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: DatabaseSync): void {
|
export function runMigrations(db: DatabaseSync): void {
|
||||||
|
|||||||
74
api/db/migrations/0011_split_favicon_thumbnail.ts
Normal file
74
api/db/migrations/0011_split_favicon_thumbnail.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
// Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
|
||||||
|
// `faviconUrl` field.
|
||||||
|
//
|
||||||
|
// The extraction cascade used to end at the page's icon and then at a guessed
|
||||||
|
// `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just
|
||||||
|
// held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72
|
||||||
|
// box. The cascade now stops at real artwork, and an absent `thumbnailUrl`
|
||||||
|
// means "no artwork", which is what lets the frontend draw a placeholder.
|
||||||
|
// This migration gives rows written before that change the same meaning.
|
||||||
|
//
|
||||||
|
// Purely local — it classifies the already-stored URL and makes no network
|
||||||
|
// calls, so `accentColor` is deliberately not backfilled: the frontend derives
|
||||||
|
// a stable hue from the hostname whenever one is missing, and
|
||||||
|
// `refreshDumpMetadata` fetches the real color on demand.
|
||||||
|
//
|
||||||
|
// Idempotent: rows that already carry a `faviconUrl` are skipped, so a fresh
|
||||||
|
// database built from schema.sql is a no-op.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a stored thumbnail URL is really a site icon.
|
||||||
|
*
|
||||||
|
* Deliberately loose. A false positive (a genuine cover image living under
|
||||||
|
* `/assets/icons/`) renders contained on a tinted field instead of
|
||||||
|
* cover-cropped — mildly wrong, never broken — so chasing them isn't worth the
|
||||||
|
* extra rules.
|
||||||
|
*/
|
||||||
|
function isIconUrl(raw: string): boolean {
|
||||||
|
let pathname: string;
|
||||||
|
try {
|
||||||
|
pathname = new URL(raw).pathname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /favicon|apple-touch-icon|\/icons?\//i.test(pathname) ||
|
||||||
|
/\.(ico|svg)$/i.test(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function up(db: DatabaseSync): void {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT id, rich_content FROM dumps
|
||||||
|
WHERE kind = 'url' AND rich_content IS NOT NULL;`,
|
||||||
|
).all() as { id: string; rich_content: string }[];
|
||||||
|
|
||||||
|
const update = db.prepare(
|
||||||
|
`UPDATE dumps SET rich_content = ? WHERE id = ?;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let patched = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
let rich: { thumbnailUrl?: string; faviconUrl?: string };
|
||||||
|
try {
|
||||||
|
rich = JSON.parse(row.rich_content);
|
||||||
|
} catch {
|
||||||
|
continue; // malformed payload — leave it untouched
|
||||||
|
}
|
||||||
|
if (rich.faviconUrl || !rich.thumbnailUrl) continue;
|
||||||
|
if (!isIconUrl(rich.thumbnailUrl)) continue;
|
||||||
|
|
||||||
|
const { thumbnailUrl: _dropped, ...rest } = rich;
|
||||||
|
update.run(
|
||||||
|
JSON.stringify({ ...rest, faviconUrl: rich.thumbnailUrl }),
|
||||||
|
row.id,
|
||||||
|
);
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patched > 0) {
|
||||||
|
console.log(
|
||||||
|
`[migrate] 0011: reclassified ${patched} favicon thumbnail(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ interface OGMeta {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
/** True only for real artwork — a square site icon must not claim a wide card. */
|
||||||
|
imageIsWide?: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +25,9 @@ function escapeAttr(s: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTags(meta: OGMeta): string {
|
function buildTags(meta: OGMeta): string {
|
||||||
const card = meta.imageUrl ? "summary_large_image" : "summary";
|
const card = meta.imageUrl && meta.imageIsWide
|
||||||
|
? "summary_large_image"
|
||||||
|
: "summary";
|
||||||
const tags = [
|
const tags = [
|
||||||
`<title>${escapeAttr(meta.title)}</title>`,
|
`<title>${escapeAttr(meta.title)}</title>`,
|
||||||
`<meta property="og:site_name" content="${OG_SITE_NAME}" />`,
|
`<meta property="og:site_name" content="${OG_SITE_NAME}" />`,
|
||||||
@@ -105,10 +109,14 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
|||||||
} else if (dump.richContent?.thumbnailUrl) {
|
} else if (dump.richContent?.thumbnailUrl) {
|
||||||
imageUrl = dump.richContent.thumbnailUrl;
|
imageUrl = dump.richContent.thumbnailUrl;
|
||||||
}
|
}
|
||||||
|
// No artwork: fall back to our own site icon rather than the target's
|
||||||
|
// favicon, which is typically too small to survive a social card (and may
|
||||||
|
// be hotlink-protected).
|
||||||
meta = {
|
meta = {
|
||||||
title: dump.title,
|
title: dump.title,
|
||||||
description: dump.comment,
|
description: dump.comment,
|
||||||
imageUrl,
|
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||||
|
imageIsWide: !!imageUrl,
|
||||||
url: pageUrl,
|
url: pageUrl,
|
||||||
};
|
};
|
||||||
} catch { /* not found or private — serve default */ }
|
} catch { /* not found or private — serve default */ }
|
||||||
@@ -121,7 +129,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
|||||||
meta = {
|
meta = {
|
||||||
title: user.username,
|
title: user.username,
|
||||||
description: user.description,
|
description: user.description,
|
||||||
imageUrl,
|
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||||
url: pageUrl,
|
url: pageUrl,
|
||||||
};
|
};
|
||||||
} catch { /* not found */ }
|
} catch { /* not found */ }
|
||||||
@@ -135,7 +143,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
|||||||
meta = {
|
meta = {
|
||||||
title: playlist.title,
|
title: playlist.title,
|
||||||
description: playlist.description,
|
description: playlist.description,
|
||||||
imageUrl,
|
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||||
url: pageUrl,
|
url: pageUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ export interface RichContent {
|
|||||||
siteName?: string;
|
siteName?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/**
|
||||||
|
* A real preview image (og:image, a large content image, a video still).
|
||||||
|
* Never a favicon — an absent value means "this page offers no artwork",
|
||||||
|
* which is what makes the generated placeholder possible.
|
||||||
|
*/
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
|
/** The page's own icon, used as the placeholder's glyph. */
|
||||||
|
faviconUrl?: string;
|
||||||
|
/** The page's declared brand color, normalized to `#rrggbb`. */
|
||||||
|
accentColor?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
embedUrl?: string;
|
embedUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
dumpRowToApi,
|
dumpRowToApi,
|
||||||
isDumpRow,
|
isDumpRow,
|
||||||
} from "../model/db.ts";
|
} from "../model/db.ts";
|
||||||
import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts";
|
import {
|
||||||
|
fetchRichContent,
|
||||||
|
isValidHttpUrl,
|
||||||
|
tryFetchRichContent,
|
||||||
|
} from "./rich-content-service.ts";
|
||||||
import {
|
import {
|
||||||
broadcastDumpDeleted,
|
broadcastDumpDeleted,
|
||||||
broadcastDumpUpdated,
|
broadcastDumpUpdated,
|
||||||
@@ -596,7 +600,19 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const richContent = await fetchRichContent(dump.url);
|
// A failed fetch yields a stub with no title and no thumbnail. Writing that
|
||||||
|
// over a dump that already has good metadata would silently destroy it and
|
||||||
|
// rename the dump to its bare hostname, so bail out instead — the caller
|
||||||
|
// surfaces the error and nothing is lost.
|
||||||
|
const { ok, content: richContent } = await tryFetchRichContent(dump.url);
|
||||||
|
if (!ok) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
502,
|
||||||
|
"Could not reach the page to refresh its metadata",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const title = richContent?.title ?? titleFromUrl(dump.url);
|
const title = richContent?.title ?? titleFromUrl(dump.url);
|
||||||
|
|
||||||
const updatedDump: Dump = { ...dump, title, richContent };
|
const updatedDump: Dump = { ...dump, title, richContent };
|
||||||
@@ -605,6 +621,8 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
|||||||
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
|
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
|
||||||
).run(row.title, row.rich_content, row.id);
|
).run(row.title, row.rich_content, row.id);
|
||||||
|
|
||||||
|
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||||
|
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import {
|
|||||||
extractFirstContentImage,
|
extractFirstContentImage,
|
||||||
extractJsonLd,
|
extractJsonLd,
|
||||||
extractLargeImage,
|
extractLargeImage,
|
||||||
|
extractMaskIconColor,
|
||||||
extractMetaName,
|
extractMetaName,
|
||||||
extractOgTag,
|
extractOgTag,
|
||||||
extractPageTitle,
|
extractPageTitle,
|
||||||
|
extractThemeColor,
|
||||||
fetchWithTimeout,
|
fetchWithTimeout,
|
||||||
|
normalizeCssColor,
|
||||||
} from "../rich-content-service.ts";
|
} from "../rich-content-service.ts";
|
||||||
|
|
||||||
export const genericProvider: RichContentProvider = {
|
export const genericProvider: RichContentProvider = {
|
||||||
@@ -59,21 +62,37 @@ export const genericProvider: RichContentProvider = {
|
|||||||
ld.description ??
|
ld.description ??
|
||||||
extractMetaName(html, "description");
|
extractMetaName(html, "description");
|
||||||
|
|
||||||
// Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> → first content <img> → best icon → /favicon.ico
|
// Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> →
|
||||||
|
// first content <img>. The chain deliberately stops there: a favicon is not
|
||||||
|
// artwork, and pretending otherwise means every art-less page gets a 16×16
|
||||||
|
// icon cover-cropped into a 128×72 box. No match here means "no artwork",
|
||||||
|
// and the frontend draws a generated placeholder instead.
|
||||||
const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ??
|
const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ??
|
||||||
extractMetaName(html, "twitter:image") ??
|
extractMetaName(html, "twitter:image") ??
|
||||||
ld.thumbnailUrl ??
|
ld.thumbnailUrl ??
|
||||||
extractLargeImage(html, url) ??
|
extractLargeImage(html, url) ??
|
||||||
extractFirstContentImage(html, url) ??
|
extractFirstContentImage(html, url);
|
||||||
extractBestIcon(html, url) ??
|
|
||||||
|
// Icon and brand color are independent facts about the page, so they're
|
||||||
|
// collected whether or not there's artwork. `/favicon.ico` is a guess; when
|
||||||
|
// it 404s the placeholder falls back to the site's initial.
|
||||||
|
const faviconUrl = extractBestIcon(html, url) ??
|
||||||
`${new URL(url).origin}/favicon.ico`;
|
`${new URL(url).origin}/favicon.ico`;
|
||||||
|
|
||||||
|
const accentColor = normalizeCssColor(
|
||||||
|
extractThemeColor(html) ??
|
||||||
|
extractMetaName(html, "msapplication-TileColor") ??
|
||||||
|
extractMaskIconColor(html),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "generic",
|
type: "generic",
|
||||||
url,
|
url,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
|
faviconUrl,
|
||||||
|
accentColor,
|
||||||
siteName,
|
siteName,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import type { RichContentProvider } from "../rich-content-service.ts";
|
|||||||
import { getDump } from "../dump-service.ts";
|
import { getDump } from "../dump-service.ts";
|
||||||
import { getUserByUsername } from "../user-service.ts";
|
import { getUserByUsername } from "../user-service.ts";
|
||||||
import { getPlaylistById } from "../playlist-service.ts";
|
import { getPlaylistById } from "../playlist-service.ts";
|
||||||
import { PUBLIC_URL } from "../../config.ts";
|
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
|
||||||
|
|
||||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||||
const USER_RE = /^\/users\/([^/]+)$/;
|
const USER_RE = /^\/users\/([^/]+)$/;
|
||||||
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
|
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
|
||||||
|
|
||||||
|
// Internal links carry gerbeur's own branding, so an art-less one still gets a
|
||||||
|
// recognizable placeholder instead of a generic tint.
|
||||||
|
const SELF_BRANDING = {
|
||||||
|
siteName: "gerbeur",
|
||||||
|
faviconUrl: `${PUBLIC_URL}/apple-touch-icon.png`,
|
||||||
|
accentColor: THEME_COLOR,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const selfProvider: RichContentProvider = {
|
export const selfProvider: RichContentProvider = {
|
||||||
name: "self",
|
name: "self",
|
||||||
|
|
||||||
@@ -38,7 +46,7 @@ export const selfProvider: RichContentProvider = {
|
|||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: "generic",
|
type: "generic",
|
||||||
url,
|
url,
|
||||||
siteName: "gerbeur",
|
...SELF_BRANDING,
|
||||||
title: dump.title,
|
title: dump.title,
|
||||||
description: dump.comment,
|
description: dump.comment,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
@@ -54,7 +62,7 @@ export const selfProvider: RichContentProvider = {
|
|||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: "generic",
|
type: "generic",
|
||||||
url,
|
url,
|
||||||
siteName: "gerbeur",
|
...SELF_BRANDING,
|
||||||
title: user.username,
|
title: user.username,
|
||||||
description: user.description,
|
description: user.description,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
@@ -70,7 +78,7 @@ export const selfProvider: RichContentProvider = {
|
|||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: "generic",
|
type: "generic",
|
||||||
url,
|
url,
|
||||||
siteName: "gerbeur",
|
...SELF_BRANDING,
|
||||||
title: playlist.title,
|
title: playlist.title,
|
||||||
description: playlist.description,
|
description: playlist.description,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
|
|||||||
@@ -184,6 +184,117 @@ export function extractPageTitle(html: string): string | undefined {
|
|||||||
return match ? decodeHtmlEntities(match[1].trim()) : undefined;
|
return match ? decodeHtmlEntities(match[1].trim()) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Brand color helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the page's `theme-color`.
|
||||||
|
*
|
||||||
|
* Sites routinely ship the tag twice, scoped by `media`, and often list the
|
||||||
|
* dark-scheme one first — so unlike `extractMetaName` this can't just take the
|
||||||
|
* first match. Prefer the unscoped tag, then the light-scheme one, then any.
|
||||||
|
*/
|
||||||
|
export function extractThemeColor(html: string): string | undefined {
|
||||||
|
const tagRe = /<meta\b[^>]*>/gi;
|
||||||
|
const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i;
|
||||||
|
const mediaRe = /\bmedia=(["'])([\s\S]*?)\1/i;
|
||||||
|
let unscoped: string | undefined;
|
||||||
|
let light: string | undefined;
|
||||||
|
let any: string | undefined;
|
||||||
|
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = tagRe.exec(html)) !== null) {
|
||||||
|
const tag = m[0];
|
||||||
|
if (!/\bname=["']theme-color["']/i.test(tag)) continue;
|
||||||
|
const content = contentRe.exec(tag)?.[2];
|
||||||
|
if (!content) continue;
|
||||||
|
const media = mediaRe.exec(tag)?.[2];
|
||||||
|
if (!media) unscoped ??= content;
|
||||||
|
else if (/light/i.test(media)) light ??= content;
|
||||||
|
any ??= content;
|
||||||
|
}
|
||||||
|
return unscoped ?? light ?? any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */
|
||||||
|
export function extractMaskIconColor(html: string): string | undefined {
|
||||||
|
const linkRe = /<link[^>]+>/gi;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = linkRe.exec(html)) !== null) {
|
||||||
|
const tag = m[0];
|
||||||
|
if (!/\brel=["'][^"']*mask-icon[^"']*["']/i.test(tag)) continue;
|
||||||
|
const color = /\bcolor=(["'])([\s\S]*?)\1/i.exec(tag)?.[2];
|
||||||
|
if (color) return color;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handful of named colors that actually show up in `theme-color`. The full
|
||||||
|
// 148-name table isn't worth carrying for the long tail.
|
||||||
|
const NAMED_COLORS: Record<string, string> = {
|
||||||
|
black: "#000000",
|
||||||
|
white: "#ffffff",
|
||||||
|
red: "#ff0000",
|
||||||
|
green: "#008000",
|
||||||
|
blue: "#0000ff",
|
||||||
|
yellow: "#ffff00",
|
||||||
|
orange: "#ffa500",
|
||||||
|
purple: "#800080",
|
||||||
|
gray: "#808080",
|
||||||
|
grey: "#808080",
|
||||||
|
silver: "#c0c0c0",
|
||||||
|
maroon: "#800000",
|
||||||
|
navy: "#000080",
|
||||||
|
teal: "#008080",
|
||||||
|
olive: "#808000",
|
||||||
|
lime: "#00ff00",
|
||||||
|
aqua: "#00ffff",
|
||||||
|
cyan: "#00ffff",
|
||||||
|
fuchsia: "#ff00ff",
|
||||||
|
magenta: "#ff00ff",
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampByte(n: number): number {
|
||||||
|
return Math.max(0, Math.min(255, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when
|
||||||
|
* it isn't one of the forms sites actually use. Alpha is dropped — the color is
|
||||||
|
* only ever used as a tint over the app's own surface.
|
||||||
|
*/
|
||||||
|
export function normalizeCssColor(raw: string | undefined): string | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const value = raw.trim().toLowerCase();
|
||||||
|
|
||||||
|
const named = NAMED_COLORS[value];
|
||||||
|
if (named) return named;
|
||||||
|
|
||||||
|
const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1];
|
||||||
|
if (hex) {
|
||||||
|
if (hex.length === 3 || hex.length === 4) {
|
||||||
|
const [r, g, b] = [...hex.slice(0, 3)];
|
||||||
|
return `#${r}${r}${g}${g}${b}${b}`;
|
||||||
|
}
|
||||||
|
if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
|
||||||
|
if (rgb) {
|
||||||
|
const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3);
|
||||||
|
if (parts.length !== 3) return undefined;
|
||||||
|
const bytes = parts.map((p) => {
|
||||||
|
const n = parseFloat(p);
|
||||||
|
if (!Number.isFinite(n)) return NaN;
|
||||||
|
return clampByte(p.endsWith("%") ? (n / 100) * 255 : n);
|
||||||
|
});
|
||||||
|
if (bytes.some(Number.isNaN)) return undefined;
|
||||||
|
return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// ── JSON-LD helpers (file-private) ────────────────────────────────────────────
|
// ── JSON-LD helpers (file-private) ────────────────────────────────────────────
|
||||||
|
|
||||||
type JsonLdResult = {
|
type JsonLdResult = {
|
||||||
@@ -281,6 +392,10 @@ export function extractLargeImage(
|
|||||||
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
|
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
|
||||||
* them by declared size (largest wins), and return the best resolved URL.
|
* them by declared size (largest wins), and return the best resolved URL.
|
||||||
* Falls back to the first match when no `sizes` attribute is present.
|
* Falls back to the first match when no `sizes` attribute is present.
|
||||||
|
*
|
||||||
|
* SVG icons are ranked above everything: they carry no `sizes` attribute, so
|
||||||
|
* they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to
|
||||||
|
* whatever size the placeholder renders them at.
|
||||||
*/
|
*/
|
||||||
export function extractBestIcon(
|
export function extractBestIcon(
|
||||||
html: string,
|
html: string,
|
||||||
@@ -302,7 +417,13 @@ export function extractBestIcon(
|
|||||||
if (!href) continue;
|
if (!href) continue;
|
||||||
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
|
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
|
||||||
const sm = sizesStr.match(/(\d+)x(\d+)/i);
|
const sm = sizesStr.match(/(\d+)x(\d+)/i);
|
||||||
const area = sm ? parseInt(sm[1]) * parseInt(sm[2]) : 0;
|
const isSvg = /\.svg(\?|$)/i.test(href) ||
|
||||||
|
/\btype=["']image\/svg\+xml["']/i.test(tag);
|
||||||
|
const area = isSvg
|
||||||
|
? Number.MAX_SAFE_INTEGER
|
||||||
|
: sm
|
||||||
|
? parseInt(sm[1]) * parseInt(sm[2])
|
||||||
|
: 0;
|
||||||
try {
|
try {
|
||||||
candidates.push({ href: new URL(href, baseUrl).toString(), area });
|
candidates.push({ href: new URL(href, baseUrl).toString(), area });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -431,24 +552,46 @@ export function isValidHttpUrl(raw: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRichContent(
|
export interface FetchRichContentResult {
|
||||||
|
/** False when the page couldn't be reached at all, as opposed to reached and
|
||||||
|
* found to carry no metadata. Callers that already hold good metadata must
|
||||||
|
* not overwrite it with a failure stub. */
|
||||||
|
ok: boolean;
|
||||||
|
content?: RichContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch metadata for `url`, reporting whether the fetch itself succeeded.
|
||||||
|
*
|
||||||
|
* On failure it still yields a minimal stub so a *new* dump has something
|
||||||
|
* displayable, but `ok: false` lets callers with existing metadata keep it.
|
||||||
|
*/
|
||||||
|
export async function tryFetchRichContent(
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<RichContent | undefined> {
|
): Promise<FetchRichContentResult> {
|
||||||
try {
|
try {
|
||||||
const provider = providers.find((p) => p.matches(url))!;
|
const provider = providers.find((p) => p.matches(url))!;
|
||||||
return await provider.fetch(url);
|
return { ok: true, content: await provider.fetch(url) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
|
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
|
||||||
// Return a minimal stub so the caller always gets something displayable
|
|
||||||
// (e.g. when the site has a bad TLS cert or the fetch times out).
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
|
ok: false,
|
||||||
|
content: {
|
||||||
type: "generic",
|
type: "generic",
|
||||||
url,
|
url,
|
||||||
siteName: new URL(url).hostname.replace(/^www\./, ""),
|
siteName: new URL(url).hostname.replace(/^www\./, ""),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return { ok: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Metadata for `url`, or a minimal stub when it can't be fetched. */
|
||||||
|
export async function fetchRichContent(
|
||||||
|
url: string,
|
||||||
|
): Promise<RichContent | undefined> {
|
||||||
|
return (await tryFetchRichContent(url)).content;
|
||||||
|
}
|
||||||
|
|||||||
76
src/App.css
76
src/App.css
@@ -2534,6 +2534,12 @@ body.has-player .chat-fab {
|
|||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dump-edit-refresh-error {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.dump-edit-thumbnail-row {
|
.dump-edit-thumbnail-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3009,6 +3015,76 @@ body.has-player .chat-fab {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Generated thumbnail placeholder ──
|
||||||
|
Stands in for pages that offer no preview image. The site's own color is
|
||||||
|
mixed *into* the theme surface rather than painted raw, so an arbitrary
|
||||||
|
third-party accent can never break contrast against --color-text — in any
|
||||||
|
style, in either color scheme. Themes tune --thumb-tint-strength alone. */
|
||||||
|
/* Zero-specificity sizing: the placeholder fills its box by default, but any
|
||||||
|
class it shares with the <img> it replaces (.rich-content-thumbnail's 180px,
|
||||||
|
.rich-content-compact-thumbnail's 36px) still wins, so it lands exactly where
|
||||||
|
the image would have. */
|
||||||
|
:where(.thumb-placeholder) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.12)),
|
||||||
|
color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--thumb-tint, var(--color-accent)) var(--thumb-tint-strength, 34%),
|
||||||
|
var(--color-surface)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder-icon {
|
||||||
|
max-width: 44%;
|
||||||
|
max-height: 44%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder-initials {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.75;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The initial has no intrinsic size, so scale it per surface. */
|
||||||
|
.dump-card-preview .thumb-placeholder-initials,
|
||||||
|
.playlist-card-preview .thumb-placeholder-initials {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-card-image .thumb-placeholder-initials {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content-thumbnail.thumb-placeholder .thumb-placeholder-initials {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `.rich-content-thumbnail` sizes an <img> by width alone and lets the natural
|
||||||
|
aspect supply the height, which a <div> has none of. Stretching to the card's
|
||||||
|
height matches the image; the floor keeps it from collapsing when there's
|
||||||
|
little text, or once the card stacks on narrow screens. */
|
||||||
|
.rich-content-thumbnail.thumb-placeholder {
|
||||||
|
height: auto;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fill the 48×48 preview box and center content for media buttons */
|
/* Fill the 48×48 preview box and center content for media buttons */
|
||||||
.dump-card-preview .rich-content-thumbnail-btn {
|
.dump-card-preview .rich-content-thumbnail-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useCategories } from "../hooks/useCategories.ts";
|
|||||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||||
import FilePreview from "./FilePreview.tsx";
|
import FilePreview from "./FilePreview.tsx";
|
||||||
import RichContentCard from "./RichContentCard.tsx";
|
import RichContentCard from "./RichContentCard.tsx";
|
||||||
|
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
|
||||||
import { VoteButton } from "./VoteButton.tsx";
|
import { VoteButton } from "./VoteButton.tsx";
|
||||||
import { Markdown } from "./Markdown.tsx";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
import { Tooltip } from "./Tooltip.tsx";
|
import { Tooltip } from "./Tooltip.tsx";
|
||||||
@@ -65,7 +66,7 @@ export function DumpCard(
|
|||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: <span className="dump-card-preview-icon">🔗</span>}
|
: <ThumbnailPlaceholder url={dump.url} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
|
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function ImagePicker({
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
className="img-picker-img"
|
className="img-picker-img"
|
||||||
style={{ borderRadius }}
|
style={{ borderRadius }}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useContext } from "react";
|
|||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { Plural, Trans } from "@lingui/react/macro";
|
import { Plural, Trans } from "@lingui/react/macro";
|
||||||
import type { Dump } from "../model.ts";
|
import type { Dump } from "../model.ts";
|
||||||
import { API_URL } from "../config/api.ts";
|
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
@@ -11,6 +10,7 @@ import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayou
|
|||||||
import { VoteButton } from "./VoteButton.tsx";
|
import { VoteButton } from "./VoteButton.tsx";
|
||||||
import { Markdown } from "./Markdown.tsx";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
import { Tooltip } from "./Tooltip.tsx";
|
import { Tooltip } from "./Tooltip.tsx";
|
||||||
|
import Thumbnail from "./Thumbnail.tsx";
|
||||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||||
|
|
||||||
export type { JournalShape };
|
export type { JournalShape };
|
||||||
@@ -41,31 +41,13 @@ export function JournalCard(
|
|||||||
navigate(dumpUrl(dump));
|
navigate(dumpUrl(dump));
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawThumbnail =
|
const thumbnailUrl =
|
||||||
dump.kind === "file" && dump.fileMime?.startsWith("image/")
|
dump.kind === "file" && dump.fileMime?.startsWith("image/")
|
||||||
? dumpFileUrl(dump, token)
|
? dumpFileUrl(dump, token)
|
||||||
: dump.thumbnailMime
|
: dump.thumbnailMime
|
||||||
? dumpThumbnailUrl(dump, token)
|
? dumpThumbnailUrl(dump, token)
|
||||||
: (dump.richContent?.thumbnailUrl ?? null);
|
: (dump.richContent?.thumbnailUrl ?? null);
|
||||||
|
|
||||||
// Route external HTTP thumbnails through the server proxy to avoid
|
|
||||||
// mixed-content blocks when the frontend is served over HTTPS.
|
|
||||||
const thumbnailUrl = (() => {
|
|
||||||
if (!rawThumbnail) return null;
|
|
||||||
try {
|
|
||||||
const u = new URL(rawThumbnail);
|
|
||||||
if (
|
|
||||||
u.protocol === "http:" && u.hostname !== "localhost" &&
|
|
||||||
u.hostname !== "127.0.0.1"
|
|
||||||
) {
|
|
||||||
return `${API_URL}/api/proxy-image?url=${
|
|
||||||
encodeURIComponent(rawThumbnail)
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
} catch { /* relative URL */ }
|
|
||||||
return rawThumbnail;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Content mode is independent of grid footprint: a thumbnailed dump reads as
|
// Content mode is independent of grid footprint: a thumbnailed dump reads as
|
||||||
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and
|
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and
|
||||||
// everything else falls back to a typographic text card.
|
// everything else falls back to a typographic text card.
|
||||||
@@ -167,12 +149,13 @@ export function JournalCard(
|
|||||||
: handleNavigate}
|
: handleNavigate}
|
||||||
>
|
>
|
||||||
<div className="journal-card-image">
|
<div className="journal-card-image">
|
||||||
<img
|
<Thumbnail
|
||||||
src={thumbnailUrl ?? undefined}
|
src={thumbnailUrl ?? undefined}
|
||||||
alt=""
|
placeholder={{
|
||||||
loading="lazy"
|
url: dump.url,
|
||||||
onError={(e) => {
|
accentColor: dump.richContent?.accentColor,
|
||||||
(e.target as HTMLImageElement).style.visibility = "hidden";
|
faviconUrl: dump.richContent?.faviconUrl,
|
||||||
|
siteName: dump.richContent?.siteName,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{embedUrl && (
|
{embedUrl && (
|
||||||
|
|||||||
@@ -1,21 +1,7 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import type { RichContent } from "../model.ts";
|
import type { RichContent } from "../model.ts";
|
||||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||||
import { API_URL } from "../config/api.ts";
|
import Thumbnail from "./Thumbnail.tsx";
|
||||||
|
|
||||||
/** Route HTTP thumbnail URLs through the server proxy to avoid mixed-content blocks. */
|
|
||||||
function proxyIfHttp(url: string): string {
|
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
if (
|
|
||||||
u.protocol === "http:" && u.hostname !== "localhost" &&
|
|
||||||
u.hostname !== "127.0.0.1"
|
|
||||||
) {
|
|
||||||
return `${API_URL}/api/proxy-image?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
} catch { /* relative URL — leave as-is */ }
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RichContentCardProps {
|
interface RichContentCardProps {
|
||||||
richContent: RichContent;
|
richContent: RichContent;
|
||||||
@@ -31,12 +17,26 @@ export default function RichContentCard(
|
|||||||
RichContentCardProps,
|
RichContentCardProps,
|
||||||
) {
|
) {
|
||||||
const { play, current, playing } = useContext(PlayerContext);
|
const { play, current, playing } = useContext(PlayerContext);
|
||||||
const thumbnailSrc = thumbnailOverrideUrl ??
|
const thumbnailSrc = thumbnailOverrideUrl ?? richContent.thumbnailUrl;
|
||||||
(richContent.thumbnailUrl
|
|
||||||
? proxyIfHttp(richContent.thumbnailUrl)
|
const placeholder = {
|
||||||
: undefined);
|
url: richContent.url,
|
||||||
|
accentColor: richContent.accentColor,
|
||||||
|
faviconUrl: richContent.faviconUrl,
|
||||||
|
siteName: richContent.siteName,
|
||||||
|
};
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
|
const thumbnail = (
|
||||||
|
<Thumbnail
|
||||||
|
src={thumbnailSrc}
|
||||||
|
alt={richContent.title ?? ""}
|
||||||
|
className="rich-content-compact-thumbnail"
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderClassName="rich-content-compact-thumbnail"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (richContent.embedUrl) {
|
if (richContent.embedUrl) {
|
||||||
const isActive = current?.kind === "embed" &&
|
const isActive = current?.kind === "embed" &&
|
||||||
current.embedUrl === richContent.embedUrl;
|
current.embedUrl === richContent.embedUrl;
|
||||||
@@ -60,18 +60,7 @@ export default function RichContentCard(
|
|||||||
}}
|
}}
|
||||||
aria-label={isPlaying ? "Pause" : "Play"}
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
>
|
>
|
||||||
{thumbnailSrc
|
{thumbnail}
|
||||||
? (
|
|
||||||
<img
|
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={richContent.title ?? ""}
|
|
||||||
className="rich-content-compact-thumbnail"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: <span className="rich-content-compact-icon">▶</span>}
|
|
||||||
<span className="rich-content-play-overlay">
|
<span className="rich-content-play-overlay">
|
||||||
{isPlaying ? "⏸" : "▶"}
|
{isPlaying ? "⏸" : "▶"}
|
||||||
</span>
|
</span>
|
||||||
@@ -87,26 +76,24 @@ export default function RichContentCard(
|
|||||||
className="rich-content-compact"
|
className="rich-content-compact"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{thumbnailSrc
|
{thumbnail}
|
||||||
? (
|
|
||||||
<img
|
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={richContent.title ?? ""}
|
|
||||||
className="rich-content-compact-thumbnail"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: <span className="rich-content-compact-icon">🔗</span>}
|
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canPlay = !!richContent.embedUrl;
|
const canPlay = !!richContent.embedUrl;
|
||||||
|
|
||||||
const thumbnail = thumbnailSrc && (
|
const thumbnailImg = (
|
||||||
canPlay
|
<Thumbnail
|
||||||
|
src={thumbnailSrc}
|
||||||
|
alt={richContent.title ?? ""}
|
||||||
|
className="rich-content-thumbnail"
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderClassName="rich-content-thumbnail"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const thumbnail = canPlay
|
||||||
? (
|
? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -121,28 +108,11 @@ export default function RichContentCard(
|
|||||||
})}
|
})}
|
||||||
aria-label="Play"
|
aria-label="Play"
|
||||||
>
|
>
|
||||||
<img
|
{thumbnailImg}
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={richContent.title ?? ""}
|
|
||||||
className="rich-content-thumbnail"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="rich-content-play-overlay">▶</span>
|
<span className="rich-content-play-overlay">▶</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
: (
|
: thumbnailImg;
|
||||||
<img
|
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={richContent.title ?? ""}
|
|
||||||
className="rich-content-thumbnail"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rich-content-card rich-content-card--${richContent.type}`}>
|
<div className={`rich-content-card rich-content-card--${richContent.type}`}>
|
||||||
|
|||||||
73
src/components/Thumbnail.tsx
Normal file
73
src/components/Thumbnail.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { canProxyImage, proxyImageUrl } from "../utils/proxyImage.ts";
|
||||||
|
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
|
||||||
|
|
||||||
|
interface ThumbnailProps {
|
||||||
|
/** Raw image URL. Proxying is decided here — don't pre-proxy it. */
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
className?: string;
|
||||||
|
loading?: "lazy" | "eager";
|
||||||
|
/** Passed through to the placeholder when there's nothing to show. */
|
||||||
|
placeholder: {
|
||||||
|
url?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
faviconUrl?: string;
|
||||||
|
siteName?: string;
|
||||||
|
};
|
||||||
|
/** Extra class for the placeholder element only. */
|
||||||
|
placeholderClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An external thumbnail that always renders *something*.
|
||||||
|
*
|
||||||
|
* Three attempts, in order:
|
||||||
|
* 1. load it directly with no `Referer` — hotlink protection (Cloudflare's is
|
||||||
|
* one click) answers a cross-site referrer with 403, and an empty one with
|
||||||
|
* 200;
|
||||||
|
* 2. retry through `/api/proxy-image`, which fetches server-side, for hosts
|
||||||
|
* that reject an empty referrer too;
|
||||||
|
* 3. fall back to the generated placeholder.
|
||||||
|
*
|
||||||
|
* Before this existed each of these failures ended at `display: none`, leaving
|
||||||
|
* an empty framed box and no signal anywhere that a load had failed.
|
||||||
|
*/
|
||||||
|
export default function Thumbnail(
|
||||||
|
{ src, alt = "", className, loading = "lazy", placeholder, placeholderClassName }:
|
||||||
|
ThumbnailProps,
|
||||||
|
) {
|
||||||
|
const [attempt, setAttempt] = useState<"direct" | "proxied" | "failed">(
|
||||||
|
"direct",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A new src is a fresh thing to try — reset during render rather than in an
|
||||||
|
// effect (same shape as Avatar.tsx).
|
||||||
|
const [prevSrc, setPrevSrc] = useState(src);
|
||||||
|
if (prevSrc !== src) {
|
||||||
|
setPrevSrc(src);
|
||||||
|
setAttempt("direct");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!src || attempt === "failed") {
|
||||||
|
return (
|
||||||
|
<ThumbnailPlaceholder {...placeholder} className={placeholderClassName} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={attempt === "proxied"
|
||||||
|
? proxyImageUrl(src, { force: true })
|
||||||
|
: proxyImageUrl(src)}
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
loading={loading}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() =>
|
||||||
|
setAttempt(
|
||||||
|
attempt === "direct" && canProxyImage(src) ? "proxied" : "failed",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/ThumbnailPlaceholder.tsx
Normal file
66
src/components/ThumbnailPlaceholder.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { proxyImageUrl } from "../utils/proxyImage.ts";
|
||||||
|
import { initialsFor, tintFor } from "../utils/thumbnailTint.ts";
|
||||||
|
|
||||||
|
interface ThumbnailPlaceholderProps {
|
||||||
|
/** The dumped URL — seeds the fallback hue and the initial. */
|
||||||
|
url?: string;
|
||||||
|
/** The target page's declared brand color, if it had one. */
|
||||||
|
accentColor?: string;
|
||||||
|
/** The target page's icon, drawn contained over the tint. */
|
||||||
|
faviconUrl?: string;
|
||||||
|
/** Preferred source for the initial when there's no favicon. */
|
||||||
|
siteName?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in artwork for pages that offer no preview image: the site's own color
|
||||||
|
* washed over the theme surface, with its favicon centered on top — or its
|
||||||
|
* initial when the favicon is missing or fails to load.
|
||||||
|
*
|
||||||
|
* Fills whatever box it's placed in, so the same component serves the 36px
|
||||||
|
* compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile.
|
||||||
|
*/
|
||||||
|
export default function ThumbnailPlaceholder(
|
||||||
|
{ url, accentColor, faviconUrl, siteName, className }:
|
||||||
|
ThumbnailPlaceholderProps,
|
||||||
|
) {
|
||||||
|
const [iconFailed, setIconFailed] = useState(false);
|
||||||
|
|
||||||
|
// A different icon is a fresh thing to try — clear the prior failure during
|
||||||
|
// render rather than in an effect (same shape as Avatar.tsx).
|
||||||
|
const [prevIcon, setPrevIcon] = useState(faviconUrl);
|
||||||
|
if (prevIcon !== faviconUrl) {
|
||||||
|
setPrevIcon(faviconUrl);
|
||||||
|
setIconFailed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = { "--thumb-tint": tintFor({ accentColor, url }) } as
|
||||||
|
React.CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`thumb-placeholder${className ? ` ${className}` : ""}`}
|
||||||
|
style={style}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{faviconUrl && !iconFailed
|
||||||
|
? (
|
||||||
|
<img
|
||||||
|
src={proxyImageUrl(faviconUrl)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="thumb-placeholder-icon"
|
||||||
|
onError={() => setIconFailed(true)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span className="thumb-placeholder-initials">
|
||||||
|
{initialsFor(siteName, url)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,7 +41,12 @@ export interface RichContent {
|
|||||||
siteName?: string;
|
siteName?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** A real preview image — never a favicon. Absent means "no artwork". */
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
|
/** The page's own icon, used as the placeholder's glyph. */
|
||||||
|
faviconUrl?: string;
|
||||||
|
/** The page's declared brand color, normalized to `#rrggbb`. */
|
||||||
|
accentColor?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
embedUrl?: string;
|
embedUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function DumpEdit() {
|
|||||||
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [refreshError, setRefreshError] = useState<string | null>(null);
|
||||||
const [thumbUploading, setThumbUploading] = useState(false);
|
const [thumbUploading, setThumbUploading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,15 +86,20 @@ export function DumpEdit() {
|
|||||||
if (state.status !== "loaded" || state.dump.kind !== "url") return;
|
if (state.status !== "loaded" || state.dump.kind !== "url") return;
|
||||||
|
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
setRefreshError(null);
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(
|
const res = await authFetch(
|
||||||
`${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`,
|
`${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
const apiResponse = await res.json();
|
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
||||||
|
} else {
|
||||||
|
setRefreshError(apiResponse.error.message);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setRefreshError(friendlyFetchError(err));
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
@@ -235,6 +241,9 @@ export function DumpEdit() {
|
|||||||
: <Trans>Refresh metadata</Trans>}
|
: <Trans>Refresh metadata</Trans>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{refreshError && (
|
||||||
|
<p className="dump-edit-refresh-error">{refreshError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DumpEditForm
|
<DumpEditForm
|
||||||
|
|||||||
@@ -315,6 +315,20 @@
|
|||||||
[data-style="geocities"] .playlist-card-delete-btn,
|
[data-style="geocities"] .playlist-card-delete-btn,
|
||||||
[data-style="geocities"] .emoji-picker-float [frimousse-emoji],
|
[data-style="geocities"] .emoji-picker-float [frimousse-emoji],
|
||||||
[data-style="geocities"] .emoji-picker-close-btn,
|
[data-style="geocities"] .emoji-picker-close-btn,
|
||||||
|
/* Turn the tint up and give it hard diagonal bands — a flat wash reads as an
|
||||||
|
unstyled gap in a theme built on saturation. */
|
||||||
|
[data-style="geocities"] {
|
||||||
|
--thumb-tint-strength: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="geocities"] .thumb-placeholder {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(255, 255, 255, 0.14) 0 8px,
|
||||||
|
rgba(0, 0, 0, 0.14) 8px 16px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
[data-style="geocities"] .btn--ghost,
|
[data-style="geocities"] .btn--ghost,
|
||||||
[data-style="geocities"] .new-item-toggle,
|
[data-style="geocities"] .new-item-toggle,
|
||||||
[data-style="geocities"] .audio-player-btn,
|
[data-style="geocities"] .audio-player-btn,
|
||||||
|
|||||||
@@ -587,10 +587,17 @@
|
|||||||
[data-style="nyt"] .rich-content-compact-icon,
|
[data-style="nyt"] .rich-content-compact-icon,
|
||||||
[data-style="nyt"] .journal-card-glyph,
|
[data-style="nyt"] .journal-card-glyph,
|
||||||
[data-style="nyt"] .fdz__file-icon,
|
[data-style="nyt"] .fdz__file-icon,
|
||||||
|
[data-style="nyt"] .thumb-placeholder-icon,
|
||||||
[data-style="nyt"] .empty-state {
|
[data-style="nyt"] .empty-state {
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Monochrome by design — a dumped site's brand color doesn't get to tint the
|
||||||
|
page, so the placeholder is the theme's own surface with a gray glyph. */
|
||||||
|
[data-style="nyt"] {
|
||||||
|
--thumb-tint-strength: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Tighter, continuous ruled feed (no inter-item gap; the hairline
|
/* ── Tighter, continuous ruled feed (no inter-item gap; the hairline
|
||||||
bottom rules read as one column). Even vertical padding per item. ── */
|
bottom rules read as one column). Even vertical padding per item. ── */
|
||||||
[data-style="nyt"] .dump-feed {
|
[data-style="nyt"] .dump-feed {
|
||||||
|
|||||||
57
src/utils/proxyImage.ts
Normal file
57
src/utils/proxyImage.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
|
/** Our own API origin — `API_URL` is relative ("") when we're same-origin. */
|
||||||
|
function apiOrigin(): string {
|
||||||
|
try {
|
||||||
|
return new URL(API_URL, location.href).origin;
|
||||||
|
} catch {
|
||||||
|
return location.origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `url` points at a third party, i.e. whether proxying it would
|
||||||
|
* actually change anything. Our own files and thumbnails are served with no
|
||||||
|
* `Referer` checks, so retrying them through the proxy is pure waste.
|
||||||
|
*/
|
||||||
|
export function canProxyImage(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url, location.href);
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||||
|
return u.origin !== apiOrigin() && u.origin !== location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProxyImageOptions {
|
||||||
|
/**
|
||||||
|
* Proxy even when the URL is already HTTPS. Used as a retry after a direct
|
||||||
|
* load fails — typically hotlink protection rejecting our cross-site
|
||||||
|
* `Referer`, which the server-side fetch doesn't send.
|
||||||
|
*/
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route an external image through `GET /api/proxy-image`.
|
||||||
|
*
|
||||||
|
* By default only HTTP URLs are proxied, so they don't trigger mixed-content
|
||||||
|
* blocks when the frontend is served over HTTPS. `force` opts an HTTPS URL in
|
||||||
|
* as well.
|
||||||
|
*/
|
||||||
|
export function proxyImageUrl(url: string, opts: ProxyImageOptions = {}): string {
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return url; // relative or same-origin path — nothing to proxy
|
||||||
|
}
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return url;
|
||||||
|
|
||||||
|
const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||||
|
if (isLocal) return url;
|
||||||
|
if (!opts.force && u.protocol !== "http:") return url;
|
||||||
|
|
||||||
|
return `${API_URL}/api/proxy-image?url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
63
src/utils/thumbnailTint.ts
Normal file
63
src/utils/thumbnailTint.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Color and lettering for generated thumbnail placeholders.
|
||||||
|
*
|
||||||
|
* The tint is never painted on its own — it's mixed into the theme's surface
|
||||||
|
* color by CSS (`--thumb-tint`), so contrast against `--color-text` holds for
|
||||||
|
* any site color, in every style, in both light and dark.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `accentColor` comes from a dumped page's `theme-color`, so it's
|
||||||
|
* attacker-controlled. Custom properties bypass CSS value parsing and get
|
||||||
|
* substituted verbatim wherever the var is used, so the canonical form is
|
||||||
|
* re-checked here before it ever reaches a style object.
|
||||||
|
*/
|
||||||
|
export function isSafeHex(value: string | undefined): value is string {
|
||||||
|
return !!value && /^#[0-9a-f]{6}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FNV-1a — small, stable, and well spread across the hue circle. */
|
||||||
|
function hashString(seed: string): number {
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
hash ^= seed.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable hue for a string, so the same site always gets the same tint. */
|
||||||
|
export function hueFromString(seed: string): string {
|
||||||
|
return `hsl(${hashString(seed) % 360} 55% 55%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostnameOf(url: string | undefined): string | undefined {
|
||||||
|
if (!url) return undefined;
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's own brand color when it declared a usable one, otherwise a hue
|
||||||
|
* derived from its hostname — so every card gets a tint, including rows saved
|
||||||
|
* before accent extraction existed.
|
||||||
|
*/
|
||||||
|
export function tintFor(
|
||||||
|
{ accentColor, url }: { accentColor?: string; url?: string },
|
||||||
|
): string {
|
||||||
|
if (isSafeHex(accentColor)) return accentColor;
|
||||||
|
return hueFromString(hostnameOf(url) ?? url ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The letter drawn when there's no favicon to show. */
|
||||||
|
export function initialsFor(
|
||||||
|
siteName: string | undefined,
|
||||||
|
url: string | undefined,
|
||||||
|
): string {
|
||||||
|
const source = siteName?.trim() || hostnameOf(url) || url || "";
|
||||||
|
const letter = [...source].find((ch) => /\p{L}|\p{N}/u.test(ch));
|
||||||
|
return (letter ?? "?").toUpperCase();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user