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:
76
src/App.css
76
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 <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 */
|
||||
.dump-card-preview .rich-content-thumbnail-btn {
|
||||
width: 100%;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
: <span className="dump-card-preview-icon">🔗</span>}
|
||||
: <ThumbnailPlaceholder url={dump.url} />}
|
||||
</div>
|
||||
|
||||
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -47,6 +47,7 @@ export function ImagePicker({
|
||||
alt={alt}
|
||||
className="img-picker-img"
|
||||
style={{ borderRadius }}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<div className="journal-card-image">
|
||||
<img
|
||||
<Thumbnail
|
||||
src={thumbnailUrl ?? undefined}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.visibility = "hidden";
|
||||
placeholder={{
|
||||
url: dump.url,
|
||||
accentColor: dump.richContent?.accentColor,
|
||||
faviconUrl: dump.richContent?.faviconUrl,
|
||||
siteName: dump.richContent?.siteName,
|
||||
}}
|
||||
/>
|
||||
{embedUrl && (
|
||||
|
||||
@@ -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 = (
|
||||
<Thumbnail
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-compact-thumbnail"
|
||||
placeholder={placeholder}
|
||||
placeholderClassName="rich-content-compact-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
|
||||
? (
|
||||
<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>}
|
||||
{thumbnail}
|
||||
<span className="rich-content-play-overlay">
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</span>
|
||||
@@ -87,63 +76,44 @@ export default function RichContentCard(
|
||||
className="rich-content-compact"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{thumbnailSrc
|
||||
? (
|
||||
<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>}
|
||||
{thumbnail}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const canPlay = !!richContent.embedUrl;
|
||||
|
||||
const thumbnail = thumbnailSrc && (
|
||||
canPlay
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="rich-content-thumbnail-btn"
|
||||
onClick={() =>
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl: richContent.embedUrl!,
|
||||
title: richContent.title,
|
||||
type: richContent.type,
|
||||
dumpHref,
|
||||
})}
|
||||
aria-label="Play"
|
||||
>
|
||||
<img
|
||||
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>
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-thumbnail"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)
|
||||
const thumbnailImg = (
|
||||
<Thumbnail
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-thumbnail"
|
||||
placeholder={placeholder}
|
||||
placeholderClassName="rich-content-thumbnail"
|
||||
/>
|
||||
);
|
||||
|
||||
const thumbnail = canPlay
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="rich-content-thumbnail-btn"
|
||||
onClick={() =>
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl: richContent.embedUrl!,
|
||||
title: richContent.title,
|
||||
type: richContent.type,
|
||||
dumpHref,
|
||||
})}
|
||||
aria-label="Play"
|
||||
>
|
||||
{thumbnailImg}
|
||||
<span className="rich-content-play-overlay">▶</span>
|
||||
</button>
|
||||
)
|
||||
: thumbnailImg;
|
||||
|
||||
return (
|
||||
<div className={`rich-content-card rich-content-card--${richContent.type}`}>
|
||||
{thumbnail}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export function DumpEdit() {
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshError, setRefreshError] = useState<string | null>(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<RawDump>(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() {
|
||||
: <Trans>Refresh metadata</Trans>}
|
||||
</button>
|
||||
)}
|
||||
{refreshError && (
|
||||
<p className="dump-edit-refresh-error">{refreshError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DumpEditForm
|
||||
|
||||
@@ -315,6 +315,20 @@
|
||||
[data-style="geocities"] .playlist-card-delete-btn,
|
||||
[data-style="geocities"] .emoji-picker-float [frimousse-emoji],
|
||||
[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"] .new-item-toggle,
|
||||
[data-style="geocities"] .audio-player-btn,
|
||||
|
||||
@@ -587,10 +587,17 @@
|
||||
[data-style="nyt"] .rich-content-compact-icon,
|
||||
[data-style="nyt"] .journal-card-glyph,
|
||||
[data-style="nyt"] .fdz__file-icon,
|
||||
[data-style="nyt"] .thumb-placeholder-icon,
|
||||
[data-style="nyt"] .empty-state {
|
||||
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
|
||||
bottom rules read as one column). Even vertical padding per item. ── */
|
||||
[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