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} >
- { - (e.target as HTMLImageElement).style.visibility = "hidden"; + placeholder={{ + url: dump.url, + accentColor: dump.richContent?.accentColor, + faviconUrl: dump.richContent?.faviconUrl, + siteName: dump.richContent?.siteName, }} /> {embedUrl && ( diff --git a/src/components/RichContentCard.tsx b/src/components/RichContentCard.tsx index ff9d421..09f79e9 100644 --- a/src/components/RichContentCard.tsx +++ b/src/components/RichContentCard.tsx @@ -1,21 +1,7 @@ import { useContext } from "react"; import type { RichContent } from "../model.ts"; import { PlayerContext } from "../contexts/PlayerContext.ts"; -import { API_URL } from "../config/api.ts"; - -/** 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; -} +import Thumbnail from "./Thumbnail.tsx"; interface RichContentCardProps { richContent: RichContent; @@ -31,12 +17,26 @@ export default function RichContentCard( RichContentCardProps, ) { const { play, current, playing } = useContext(PlayerContext); - const thumbnailSrc = thumbnailOverrideUrl ?? - (richContent.thumbnailUrl - ? proxyIfHttp(richContent.thumbnailUrl) - : undefined); + const thumbnailSrc = thumbnailOverrideUrl ?? richContent.thumbnailUrl; + + const placeholder = { + url: richContent.url, + accentColor: richContent.accentColor, + faviconUrl: richContent.faviconUrl, + siteName: richContent.siteName, + }; if (compact) { + const thumbnail = ( + + ); + if (richContent.embedUrl) { const isActive = current?.kind === "embed" && current.embedUrl === richContent.embedUrl; @@ -60,18 +60,7 @@ export default function RichContentCard( }} aria-label={isPlaying ? "Pause" : "Play"} > - {thumbnailSrc - ? ( - {richContent.title { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - ) - : } + {thumbnail} {isPlaying ? "⏸" : "▶"} @@ -87,63 +76,44 @@ export default function RichContentCard( className="rich-content-compact" onClick={(e) => e.stopPropagation()} > - {thumbnailSrc - ? ( - {richContent.title { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - ) - : 🔗} + {thumbnail} ); } const canPlay = !!richContent.embedUrl; - const thumbnail = thumbnailSrc && ( - canPlay - ? ( - - ) - : ( - {richContent.title { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - ) + const thumbnailImg = ( + ); + const thumbnail = canPlay + ? ( + + ) + : thumbnailImg; + return (
{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 ( + {alt} + 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 ( + + ); +} diff --git a/src/model.ts b/src/model.ts index a529896..0d1f3e1 100644 --- a/src/model.ts +++ b/src/model.ts @@ -41,7 +41,12 @@ export interface RichContent { siteName?: string; title?: string; description?: string; + /** A real preview image — never a favicon. Absent means "no artwork". */ 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/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index 954ab45..5b2357c 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -53,6 +53,7 @@ export function DumpEdit() { const [confirmDelete, setConfirmDelete] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [refreshError, setRefreshError] = useState(null); const [thumbUploading, setThumbUploading] = useState(false); useEffect(() => { @@ -85,15 +86,20 @@ export function DumpEdit() { if (state.status !== "loaded" || state.dump.kind !== "url") return; setRefreshing(true); + setRefreshError(null); try { const res = await authFetch( `${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`, { method: "POST" }, ); - const apiResponse = await res.json(); + const apiResponse = parseAPIResponse(await res.json()); if (apiResponse.success) { setState({ status: "loaded", dump: deserializeDump(apiResponse.data) }); + } else { + setRefreshError(apiResponse.error.message); } + } catch (err) { + setRefreshError(friendlyFetchError(err)); } finally { setRefreshing(false); } @@ -235,6 +241,9 @@ export function DumpEdit() { : Refresh metadata} )} + {refreshError && ( +

{refreshError}

+ )}
>> 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(); +}