From 5177d89684d6e599971773696c4df02e4fac935a Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 14 Jun 2026 08:56:22 +0100 Subject: [PATCH 1/3] fix(feed): repair broken article thumbnails from doubled image URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HackerNoon's RSS media:thumbnail/media:content URLs are malformed at the source — an already-absolute CDN URL prefixed with their own origin (https://hackernoon.com/https://cdn.hackernoon.com/…), which 404s. Our ingestion stored them verbatim, and the redesigned cards SSR the , so the broken-image icon stuck: the error event fires before React hydrates and attaches onError, so the fallback never runs. - add unwrapDoubledUrl() (utils/url.ts) + unit tests - card: unwrap at render and detect pre-hydration failures via a ref callback, so a dead image collapses to no thumbnail (not a broken icon) - sanitise URLs at ingestion (fetch-rss, admin/sync-feeds — media + OG) - one-off scrub script for already-stored rows Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/admin/sync-feeds/route.ts | 15 +++- .../UnifiedContentCard/UnifiedContentCard.tsx | 23 +++-- scripts/fetch-rss.ts | 5 ++ scripts/fix-doubled-image-urls.ts | 83 +++++++++++++++++++ utils/url.test.ts | 47 +++++++++++ utils/url.ts | 18 ++++ 6 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 scripts/fix-doubled-image-urls.ts create mode 100644 utils/url.test.ts diff --git a/app/api/admin/sync-feeds/route.ts b/app/api/admin/sync-feeds/route.ts index f12a71723..d4b4b57e2 100644 --- a/app/api/admin/sync-feeds/route.ts +++ b/app/api/admin/sync-feeds/route.ts @@ -11,6 +11,7 @@ import { eq } from "drizzle-orm"; import Parser from "rss-parser"; import { customAlphabet } from "nanoid"; import { fetchOgImage } from "@/lib/og-image"; +import { ensureHttps, unwrapDoubledUrl } from "@/utils/url"; // Generate Reddit-style short IDs: lowercase + numbers, 7 characters const generateShortId = customAlphabet( @@ -128,10 +129,14 @@ function extractImageUrl(item: Parser.Item): string | null { | { url?: string; type?: string } | undefined; - if (mediaContent?.$?.url) return mediaContent.$.url; - if (mediaThumbnail?.$?.url) return mediaThumbnail.$.url; + // Some feeds (e.g. HackerNoon's media:thumbnail) prefix an already-absolute + // CDN URL with their own origin, producing a 404ing doubled URL — unwrap it. + if (mediaContent?.$?.url) + return ensureHttps(unwrapDoubledUrl(mediaContent.$.url)); + if (mediaThumbnail?.$?.url) + return ensureHttps(unwrapDoubledUrl(mediaThumbnail.$.url)); if (enclosure?.url && enclosure.type?.startsWith("image/")) - return enclosure.url; + return ensureHttps(unwrapDoubledUrl(enclosure.url)); return null; } @@ -246,7 +251,9 @@ export async function POST(request: Request) { // Fetch OG image from the article URL try { - const ogImageUrl = await fetchOgImage(item.link); + const ogImageUrl = ensureHttps( + unwrapDoubledUrl(await fetchOgImage(item.link)), + ); if (ogImageUrl) { await db .update(aggregated_article) diff --git a/components/UnifiedContentCard/UnifiedContentCard.tsx b/components/UnifiedContentCard/UnifiedContentCard.tsx index 66d427b67..4d8b7d641 100644 --- a/components/UnifiedContentCard/UnifiedContentCard.tsx +++ b/components/UnifiedContentCard/UnifiedContentCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import Link from "next/link"; import * as Sentry from "@sentry/nextjs"; import { api } from "@/server/trpc/react"; @@ -8,7 +8,7 @@ import { signIn, useSession } from "next-auth/react"; import { toast } from "sonner"; import VoteControl from "@/components/Vote/VoteControl"; import { ReportButton } from "@/components/ReportModal/ReportModal"; -import { ensureHttps } from "@/utils/url"; +import { ensureHttps, unwrapDoubledUrl } from "@/utils/url"; import { getRelativeTime } from "@/utils/relativeTime"; export type ContentType = "POST" | "LINK"; @@ -94,10 +94,18 @@ const UnifiedContentCard = ({ const [isBookmarked, setIsBookmarked] = useState(initialBookmarked); const [shared, setShared] = useState(false); + // SSR race: a broken begins loading during HTML parse and its `error` + // event can fire before React hydrates and attaches `onError`, so the handler + // never runs and the broken image sticks. This ref callback runs on mount — a + // loaded-but-zero-size image already failed — and hides it like onError would. + const checkBrokenImage = useCallback((node: HTMLImageElement | null) => { + if (node?.complete && node.naturalWidth === 0) setImageError(true); + }, []); + const { data: session } = useSession(); const utils = api.useUtils(); - const imageUrl = ensureHttps(rawImageUrl); + const imageUrl = ensureHttps(unwrapDoubledUrl(rawImageUrl)); // Card URL priority (slug ends with urlId, so it stays canonical; urlId is the // fallback when slug is missing): discussion /d/ > member /{username}/ > @@ -200,7 +208,7 @@ const UnifiedContentCard = ({ return (
@@ -226,17 +234,17 @@ const UnifiedContentCard = ({ (handleHref ? ( {authorName} ) : ( - + {authorName} ))} { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/utils/url.test.ts b/utils/url.test.ts new file mode 100644 index 000000000..8dafbe6d3 --- /dev/null +++ b/utils/url.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { unwrapDoubledUrl, ensureHttps } from "./url"; + +describe("unwrapDoubledUrl", () => { + it("unwraps a URL prefixed with another origin (HackerNoon media:thumbnail bug)", () => { + expect( + unwrapDoubledUrl( + "https://hackernoon.com/https://cdn.hackernoon.com/images/A7coZ0.png", + ), + ).toBe("https://cdn.hackernoon.com/images/A7coZ0.png"); + }); + + it("unwraps an http-wrapped https URL, keeping the inner scheme", () => { + expect( + unwrapDoubledUrl("http://example.com/https://cdn.example.com/x.png"), + ).toBe("https://cdn.example.com/x.png"); + }); + + it("leaves a normal absolute URL untouched", () => { + expect(unwrapDoubledUrl("https://cdn.thenewstack.io/media/x.png")).toBe( + "https://cdn.thenewstack.io/media/x.png", + ); + }); + + it("does not treat a scheme in the query string as a wrapper", () => { + // Only one real scheme at index 0 — a `?url=https://...` proxy param is left alone. + expect( + unwrapDoubledUrl("https://img.proxy/optimize?url=https://cdn.site/x.png"), + ).toBe("https://img.proxy/optimize?url=https://cdn.site/x.png"); + }); + + it("passes through null/empty", () => { + expect(unwrapDoubledUrl(null)).toBeNull(); + expect(unwrapDoubledUrl(undefined)).toBeNull(); + expect(unwrapDoubledUrl("")).toBeNull(); + }); + + it("composes with ensureHttps to clean a wrapped http CDN url", () => { + expect( + ensureHttps( + unwrapDoubledUrl( + "https://hackernoon.com/http://cdn.hackernoon.com/x.png", + ), + ), + ).toBe("https://cdn.hackernoon.com/x.png"); + }); +}); diff --git a/utils/url.ts b/utils/url.ts index e2878f869..aa83da800 100644 --- a/utils/url.ts +++ b/utils/url.ts @@ -29,6 +29,24 @@ export function safeExternalHref( return parsed.protocol === "https:" ? url.trim() : undefined; } +/** + * Unwraps a URL that an upstream feed prefixed with its own origin, leaving an + * already-absolute URL doubled up — e.g. HackerNoon's `media:thumbnail` serves + * `https://hackernoon.com/https://cdn.hackernoon.com/x.png`, which 404s. We + * slice from the LAST embedded scheme so the inner, real URL wins. A normal URL + * (only scheme at index 0) and a `?url=https://...` proxy param are left intact. + */ +export function unwrapDoubledUrl( + url: string | null | undefined, +): string | null { + if (!url) return null; + // Ignore a scheme that appears inside the query string — only unwrap when the + // embedded scheme sits in the path (before any `?`). + const path = url.split("?")[0]; + const i = Math.max(path.lastIndexOf("http://"), path.lastIndexOf("https://")); + return i > 0 ? url.slice(i) : url; +} + /** Upgrades an `http://` URL to `https://`. Returns null for empty input. */ export function ensureHttps(url: string | null | undefined): string | null { if (!url) return null; From ba31d9411ab138d18daa5de09fc5f1996c9eb649 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 14 Jun 2026 08:56:31 +0100 Subject: [PATCH 2/3] fix(mobile): resolve overflow, top-bar, filters and focus on small screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relaunch redesign had several mobile regressions: - cards overflowed the viewport (byline meta line couldn't shrink) — now truncates; card padding p-4 on mobile - app-main side gutters reduced to 0.75rem and per-page content wrappers drop px-4 py-8 → px-0 py-4 on mobile (parent already gutters), so text gets full reading width without double padding - top bar collapses to burger + logo + search icon ≤720px; Log in / Join free move into the nav drawer; real search SVG replaces the tiny glyph - feed filters drop to their own row below the tabs instead of cramming under them, and no longer sit in an overflow container that clipped the FilterPill dropdowns (they now open on mobile) - command palette: Esc hint becomes an X close button on mobile - search field active state is a subtle accent underline, not the global (blue-resolving) focus ring Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/scheduled_tasks.lock | 1 + .../[username]/[slug]/_userLinkDetail.tsx | 6 +- app/(app)/admin/_client.tsx | 2 +- app/(app)/admin/moderation/_client.tsx | 2 +- app/(app)/admin/sources/_client.tsx | 2 +- app/(app)/admin/tags/_client.tsx | 2 +- app/(app)/admin/users/_client.tsx | 2 +- app/(app)/company/[slug]/page.tsx | 2 +- app/(app)/draft/[id]/page.tsx | 2 +- app/(app)/feed/_client.tsx | 7 +- .../[slug]/_feedArticleContent.tsx | 2 +- .../s/[sourceSlug]/_sourceProfileClient.tsx | 2 +- app/(app)/tag/[slug]/page.tsx | 2 +- components/CommandPalette/CommandPalette.tsx | 28 +++++++- components/ContentDetail/Layout.tsx | 2 +- components/ContentDetail/PostReader.tsx | 2 +- components/Feed/Filters.tsx | 5 +- components/Layout/NavDrawer.tsx | 28 ++++++++ components/Layout/TopBar.tsx | 70 +++++++++++++++---- styles/globals.css | 4 +- 20 files changed, 138 insertions(+), 35 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 000000000..c0e01ed82 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"2e846f21-abe2-48e8-9271-db7412d07e21","pid":64036,"procStart":"Sat Jun 6 19:50:40 2026","acquiredAt":1781422714573} \ No newline at end of file diff --git a/app/(app)/[username]/[slug]/_userLinkDetail.tsx b/app/(app)/[username]/[slug]/_userLinkDetail.tsx index 3aa9ad3f0..75bdf0012 100644 --- a/app/(app)/[username]/[slug]/_userLinkDetail.tsx +++ b/app/(app)/[username]/[slug]/_userLinkDetail.tsx @@ -119,7 +119,7 @@ const UserLinkDetail = ({ username, contentSlug, initialContent }: Props) => { if (status === "pending") { return ( -
+
@@ -134,7 +134,7 @@ const UserLinkDetail = ({ username, contentSlug, initialContent }: Props) => { if (status === "error" || !linkContent) { return ( -
+
{ const isOwner = session?.user?.id === linkContent.author?.id; return ( -
+
{ const { data: reportCounts } = api.report.getCounts.useQuery(); return ( -
+

{"// "}admin diff --git a/app/(app)/admin/moderation/_client.tsx b/app/(app)/admin/moderation/_client.tsx index f993bb9f3..a4c1fe307 100644 --- a/app/(app)/admin/moderation/_client.tsx +++ b/app/(app)/admin/moderation/_client.tsx @@ -182,7 +182,7 @@ const ModerationQueue = () => { highlightedItem === id ? "ring-2 ring-accent rounded-lg" : ""; return ( -

+
{ }; return ( -
+

diff --git a/app/(app)/admin/tags/_client.tsx b/app/(app)/admin/tags/_client.tsx index dc6ab7e1f..3739e9ac8 100644 --- a/app/(app)/admin/tags/_client.tsx +++ b/app/(app)/admin/tags/_client.tsx @@ -208,7 +208,7 @@ const TagsAdmin = () => { }; return ( -

+

diff --git a/app/(app)/admin/users/_client.tsx b/app/(app)/admin/users/_client.tsx index 53b36aa26..e66112c69 100644 --- a/app/(app)/admin/users/_client.tsx +++ b/app/(app)/admin/users/_client.tsx @@ -81,7 +81,7 @@ const UserManagement = () => { : usersData?.users; return ( -

+
+
diff --git a/app/(app)/draft/[id]/page.tsx b/app/(app)/draft/[id]/page.tsx index 86ff32fda..d9e70ea81 100644 --- a/app/(app)/draft/[id]/page.tsx +++ b/app/(app)/draft/[id]/page.tsx @@ -48,7 +48,7 @@ const PreviewPage = async (props: Props) => { const content = Markdoc.transform(ast, config); return ( -
+
) : (
diff --git a/app/(app)/s/[sourceSlug]/[slug]/_feedArticleContent.tsx b/app/(app)/s/[sourceSlug]/[slug]/_feedArticleContent.tsx index b1e9ce7e5..617cf1aa1 100644 --- a/app/(app)/s/[sourceSlug]/[slug]/_feedArticleContent.tsx +++ b/app/(app)/s/[sourceSlug]/[slug]/_feedArticleContent.tsx @@ -42,7 +42,7 @@ const FeedArticleContent = ({ sourceSlug, article }: Props) => { const safeExternalUrl = safeExternalHref(article.externalUrl); return ( -
+
{ // first render (including SSR) is the real profile rather than a skeleton. if (status === "error" || !pub) { return ( -
+

Publication Not Found diff --git a/app/(app)/tag/[slug]/page.tsx b/app/(app)/tag/[slug]/page.tsx index 452e9d530..f6f5d631f 100644 --- a/app/(app)/tag/[slug]/page.tsx +++ b/app/(app)/tag/[slug]/page.tsx @@ -165,7 +165,7 @@ export default async function TagPage(props: Props) { const nextHref = hasNextPage ? `/tag/${slug}?page=${page + 1}` : null; return ( -
+

{"// "} Tag diff --git a/components/CommandPalette/CommandPalette.tsx b/components/CommandPalette/CommandPalette.tsx index 755c78f88..8e8ac8790 100644 --- a/components/CommandPalette/CommandPalette.tsx +++ b/components/CommandPalette/CommandPalette.tsx @@ -140,7 +140,8 @@ export function CommandPalette({ onClose }: CommandPaletteProps) { aria-label="Search Codú" className="w-full max-w-[600px] overflow-hidden rounded-xl border border-strong bg-elevated shadow-lg" > -

+ {/* A subtle thin accent line marks the active field — no focus ring. */} +
- + {/* Desktop closes with the Esc key; mobile gets a tap target. */} + Esc +
{ return ( -
+
{/* Breadcrumb navigation */} {breadcrumbs.length > 0 && (