diff --git a/api/db/migrate.ts b/api/db/migrate.ts
index 87d0971..b3b04cf 100644
--- a/api/db/migrate.ts
+++ b/api/db/migrate.ts
@@ -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 up0009ChatReply } from "./migrations/0009_chat_reply.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 {
name: string;
@@ -29,6 +30,7 @@ const MIGRATIONS: Migration[] = [
{ name: "0008_chat_messages", up: up0008ChatMessages },
{ name: "0009_chat_reply", up: up0009ChatReply },
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
+ { name: "0011_split_favicon_thumbnail", up: up0011SplitFaviconThumbnail },
];
export function runMigrations(db: DatabaseSync): void {
diff --git a/api/db/migrations/0011_split_favicon_thumbnail.ts b/api/db/migrations/0011_split_favicon_thumbnail.ts
new file mode 100644
index 0000000..2a506eb
--- /dev/null
+++ b/api/db/migrations/0011_split_favicon_thumbnail.ts
@@ -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)`,
+ );
+ }
+}
diff --git a/api/middleware/og.ts b/api/middleware/og.ts
index 69f6b26..a65dc8b 100644
--- a/api/middleware/og.ts
+++ b/api/middleware/og.ts
@@ -11,6 +11,8 @@ interface OGMeta {
title: string;
description?: string;
imageUrl?: string;
+ /** True only for real artwork — a square site icon must not claim a wide card. */
+ imageIsWide?: boolean;
url: string;
}
@@ -23,7 +25,9 @@ function escapeAttr(s: string): 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 = [
`
${escapeAttr(meta.title)}`,
``,
@@ -105,10 +109,14 @@ export async function ogMiddleware(ctx: Context, next: Next) {
} else if (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 = {
title: dump.title,
description: dump.comment,
- imageUrl,
+ imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
+ imageIsWide: !!imageUrl,
url: pageUrl,
};
} catch { /* not found or private — serve default */ }
@@ -121,7 +129,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
meta = {
title: user.username,
description: user.description,
- imageUrl,
+ imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
url: pageUrl,
};
} catch { /* not found */ }
@@ -135,7 +143,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
meta = {
title: playlist.title,
description: playlist.description,
- imageUrl,
+ imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
url: pageUrl,
};
}
diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts
index 18ece27..681cb11 100644
--- a/api/model/interfaces.ts
+++ b/api/model/interfaces.ts
@@ -10,7 +10,16 @@ export interface RichContent {
siteName?: string;
title?: 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;
+ /** 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;
embedUrl?: string;
}
diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts
index 68bdb1c..2695cf7 100644
--- a/api/services/dump-service.ts
+++ b/api/services/dump-service.ts
@@ -13,7 +13,11 @@ import {
dumpRowToApi,
isDumpRow,
} from "../model/db.ts";
-import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts";
+import {
+ fetchRichContent,
+ isValidHttpUrl,
+ tryFetchRichContent,
+} from "./rich-content-service.ts";
import {
broadcastDumpDeleted,
broadcastDumpUpdated,
@@ -596,7 +600,19 @@ export async function refreshDumpMetadata(dumpId: string): Promise {
);
}
- 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 updatedDump: Dump = { ...dump, title, richContent };
@@ -605,6 +621,8 @@ export async function refreshDumpMetadata(dumpId: string): Promise {
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
).run(row.title, row.rich_content, row.id);
+ if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
+
return updatedDump;
}
diff --git a/api/services/providers/generic.ts b/api/services/providers/generic.ts
index f3a00da..faff217 100644
--- a/api/services/providers/generic.ts
+++ b/api/services/providers/generic.ts
@@ -5,10 +5,13 @@ import {
extractFirstContentImage,
extractJsonLd,
extractLargeImage,
+ extractMaskIconColor,
extractMetaName,
extractOgTag,
extractPageTitle,
+ extractThemeColor,
fetchWithTimeout,
+ normalizeCssColor,
} from "../rich-content-service.ts";
export const genericProvider: RichContentProvider = {
@@ -59,21 +62,37 @@ export const genericProvider: RichContentProvider = {
ld.description ??
extractMetaName(html, "description");
- // Image: og:image (page-matched) → twitter:image → JSON-LD → large → first content → best icon → /favicon.ico
+ // Image: og:image (page-matched) → twitter:image → JSON-LD → large →
+ // first content . 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) ??
extractMetaName(html, "twitter:image") ??
ld.thumbnailUrl ??
extractLargeImage(html, url) ??
- extractFirstContentImage(html, url) ??
- extractBestIcon(html, url) ??
+ extractFirstContentImage(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`;
+ const accentColor = normalizeCssColor(
+ extractThemeColor(html) ??
+ extractMetaName(html, "msapplication-TileColor") ??
+ extractMaskIconColor(html),
+ );
+
return {
type: "generic",
url,
title,
description,
thumbnailUrl,
+ faviconUrl,
+ accentColor,
siteName,
};
},
diff --git a/api/services/providers/self.ts b/api/services/providers/self.ts
index 102779f..c5e0b14 100644
--- a/api/services/providers/self.ts
+++ b/api/services/providers/self.ts
@@ -3,12 +3,20 @@ import type { RichContentProvider } from "../rich-content-service.ts";
import { getDump } from "../dump-service.ts";
import { getUserByUsername } from "../user-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 USER_RE = /^\/users\/([^/]+)$/;
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 = {
name: "self",
@@ -38,7 +46,7 @@ export const selfProvider: RichContentProvider = {
return Promise.resolve({
type: "generic",
url,
- siteName: "gerbeur",
+ ...SELF_BRANDING,
title: dump.title,
description: dump.comment,
thumbnailUrl,
@@ -54,7 +62,7 @@ export const selfProvider: RichContentProvider = {
return Promise.resolve({
type: "generic",
url,
- siteName: "gerbeur",
+ ...SELF_BRANDING,
title: user.username,
description: user.description,
thumbnailUrl,
@@ -70,7 +78,7 @@ export const selfProvider: RichContentProvider = {
return Promise.resolve({
type: "generic",
url,
- siteName: "gerbeur",
+ ...SELF_BRANDING,
title: playlist.title,
description: playlist.description,
thumbnailUrl,
diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts
index 977a505..287432e 100644
--- a/api/services/rich-content-service.ts
+++ b/api/services/rich-content-service.ts
@@ -184,6 +184,117 @@ export function extractPageTitle(html: string): string | 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 = /]*>/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 `` (Safari pinned tabs). */
+export function extractMaskIconColor(html: string): string | undefined {
+ const linkRe = /]+>/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 = {
+ 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) ────────────────────────────────────────────
type JsonLdResult = {
@@ -281,6 +392,10 @@ export function extractLargeImage(
* Collect all `` / `` tags, rank
* them by declared size (largest wins), and return the best resolved URL.
* 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(
html: string,
@@ -302,7 +417,13 @@ export function extractBestIcon(
if (!href) continue;
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
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 {
candidates.push({ href: new URL(href, baseUrl).toString(), area });
} 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,
-): Promise {
+): Promise {
try {
const provider = providers.find((p) => p.matches(url))!;
- return await provider.fetch(url);
+ return { ok: true, content: await provider.fetch(url) };
} catch (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 {
return {
- type: "generic",
- url,
- siteName: new URL(url).hostname.replace(/^www\./, ""),
+ ok: false,
+ content: {
+ type: "generic",
+ url,
+ siteName: new URL(url).hostname.replace(/^www\./, ""),
+ },
};
} 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 {
+ return (await tryFetchRichContent(url)).content;
+}
diff --git a/src/App.css b/src/App.css
index 2ccb1fe..ba4d1c5 100644
--- a/src/App.css
+++ b/src/App.css
@@ -2534,6 +2534,12 @@ body.has-player .chat-fab {
margin-top: 0.75rem;
}
+.dump-edit-refresh-error {
+ margin-top: 0.5rem;
+ font-size: 0.85rem;
+ color: var(--color-danger);
+}
+
.dump-edit-thumbnail-row {
display: flex;
align-items: center;
@@ -3009,6 +3015,76 @@ body.has-player .chat-fab {
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 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 by width alone and lets the natural
+ aspect supply the height, which a
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 */
.dump-card-preview .rich-content-thumbnail-btn {
width: 100%;
diff --git a/src/components/DumpCard.tsx b/src/components/DumpCard.tsx
index cf5b04b..9bd45b1 100644
--- a/src/components/DumpCard.tsx
+++ b/src/components/DumpCard.tsx
@@ -8,6 +8,7 @@ import { useCategories } from "../hooks/useCategories.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import FilePreview from "./FilePreview.tsx";
import RichContentCard from "./RichContentCard.tsx";
+import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx";
import { Tooltip } from "./Tooltip.tsx";
@@ -65,7 +66,7 @@ export function DumpCard(
: undefined}
/>
)
- : 🔗}
+ : }
e.stopPropagation()}>
diff --git a/src/components/ImagePicker.tsx b/src/components/ImagePicker.tsx
index bbe4424..90b6f33 100644
--- a/src/components/ImagePicker.tsx
+++ b/src/components/ImagePicker.tsx
@@ -47,6 +47,7 @@ export function ImagePicker({
alt={alt}
className="img-picker-img"
style={{ borderRadius }}
+ referrerPolicy="no-referrer"
/>
)
: (
diff --git a/src/components/JournalCard.tsx b/src/components/JournalCard.tsx
index b466a27..30daa10 100644
--- a/src/components/JournalCard.tsx
+++ b/src/components/JournalCard.tsx
@@ -2,7 +2,6 @@ import { useContext } from "react";
import { Link, useNavigate } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts";
-import { API_URL } from "../config/api.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.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 { Markdown } from "./Markdown.tsx";
import { Tooltip } from "./Tooltip.tsx";
+import Thumbnail from "./Thumbnail.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts";
export type { JournalShape };
@@ -41,31 +41,13 @@ export function JournalCard(
navigate(dumpUrl(dump));
}
- const rawThumbnail =
+ const thumbnailUrl =
dump.kind === "file" && dump.fileMime?.startsWith("image/")
? dumpFileUrl(dump, token)
: dump.thumbnailMime
? dumpThumbnailUrl(dump, token)
: (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
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and
// everything else falls back to a typographic text card.
@@ -167,12 +149,13 @@ export function JournalCard(
: handleNavigate}
>
{thumbnail}
diff --git a/src/components/Thumbnail.tsx b/src/components/Thumbnail.tsx
new file mode 100644
index 0000000..2d691da
--- /dev/null
+++ b/src/components/Thumbnail.tsx
@@ -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 (
+
+ );
+ }
+
+ return (
+
+ setAttempt(
+ attempt === "direct" && canProxyImage(src) ? "proxied" : "failed",
+ )}
+ />
+ );
+}
diff --git a/src/components/ThumbnailPlaceholder.tsx b/src/components/ThumbnailPlaceholder.tsx
new file mode 100644
index 0000000..3b0cc58
--- /dev/null
+++ b/src/components/ThumbnailPlaceholder.tsx
@@ -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 (
+