diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte index c1443fe..9566935 100644 --- a/frontend/src/lib/components/ArticleCard.svelte +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -1,12 +1,9 @@ -
- - {#if hero || (article.image_url && imgOk)} + +
+ {#if hasImg} - {#if article.image_url && imgOk} - (imgOk = false)} /> - {:else} - {article.topic ?? 'good news'} - {/if} + (imgOk = false)} /> {/if} @@ -39,7 +37,7 @@

{article.title}

- {#if hero && article.description} + {#if (hero || !hasImg) && article.description}

{article.description}

{/if} @@ -73,12 +71,6 @@ background: linear-gradient(135deg, var(--sage-soft), #f1ece0); } .media img { width: 100%; height: 100%; object-fit: cover; } - .fallback { - position: absolute; inset: 0; display: flex; - align-items: center; justify-content: center; - font-family: var(--serif); font-style: italic; color: var(--sage-deep); opacity: 0.5; - text-transform: lowercase; letter-spacing: 0.02em; font-size: 1.1rem; - } .body { padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; } .tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.74rem; } @@ -90,7 +82,6 @@ .src { color: var(--muted); margin-left: auto; } h3 { font-size: 1.18rem; } - .hero h3 { font-size: 1.95rem; } h3 a:hover { color: var(--sage-deep); } .desc { margin: 2px 0 0; color: #3c463a; } .why { @@ -103,28 +94,56 @@ font-size: 0.76rem; border-bottom: 1px dotted var(--line); } .actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); } - /* On pointer devices, keep the tuning controls quiet until the card is hovered - or focused — they shouldn't read as interface machinery. Touch devices (no - hover) keep them visible. */ @media (hover: hover) { .actions { opacity: 0; transition: opacity 0.16s ease; } article:hover .actions, article:focus-within .actions { opacity: 1; } } - /* Text-first secondary cards (no real image): a little breathing room up top. */ + /* text-first secondary card: a small accent instead of an empty image band */ .textfirst .body { padding-top: 18px; } .textfirst .body::before { - content: ""; display: block; width: 28px; height: 3px; + content: ''; display: block; width: 28px; height: 3px; background: var(--sage-soft); border-radius: 2px; margin-bottom: 4px; } + /* hero WITH image: two columns */ .hero { display: grid; grid-template-columns: 1.1fr 1fr; } .hero .media { aspect-ratio: auto; height: 100%; min-height: 280px; } .hero .body { padding: 28px 30px; justify-content: center; gap: 12px; } + .hero h3 { font-size: 1.95rem; } + + /* hero WITHOUT image: a fully typographic lead with a faint topic wordmark */ + .herotype { + position: relative; + overflow: hidden; + background: + radial-gradient(120% 140% at 100% 0%, var(--sage-soft) 0%, transparent 55%), + linear-gradient(180deg, var(--surface), var(--surface)); + } + .herotype::after { + content: attr(data-topic); + position: absolute; + right: 8px; + bottom: -18px; + font-family: var(--serif); + font-size: clamp(4rem, 12vw, 8rem); + line-height: 1; + color: var(--sage); + opacity: 0.07; + text-transform: lowercase; + letter-spacing: -0.02em; + pointer-events: none; + user-select: none; + } + .herotype .body { position: relative; padding: 40px 36px; gap: 12px; } + .herotype h3 { font-size: 2.15rem; } + .herotype .desc { font-size: 1.05rem; } + @media (max-width: 640px) { .hero { grid-template-columns: 1fr; } .hero .media { min-height: 200px; } - .hero h3 { font-size: 1.6rem; } + .hero h3, .herotype h3 { font-size: 1.6rem; } + .herotype .body { padding: 30px 24px; } } diff --git a/goodnews/feeds.py b/goodnews/feeds.py index 2988b03..9f7500f 100644 --- a/goodnews/feeds.py +++ b/goodnews/feeds.py @@ -1,6 +1,7 @@ from __future__ import annotations import email.utils +import re import sqlite3 import urllib.error import urllib.request @@ -361,7 +362,7 @@ def _parse_rss(root: ET.Element) -> list[FeedItem]: description=_first_text(item, "description", "summary", "encoded"), author=_first_text(item, "author", "creator"), published_at=_parse_date(_first_text(item, "pubDate", "published", "updated", "date")), - image_url=_find_image_url(item), + image_url=_find_image_url(item) or _html_image(item), language=language, raw_guid=guid, ) @@ -389,7 +390,7 @@ def _parse_atom(root: ET.Element) -> list[FeedItem]: description=_first_text(entry, "summary", "content"), author=author, published_at=_parse_date(_first_text(entry, "published", "updated")), - image_url=_find_image_url(entry), + image_url=_find_image_url(entry) or _html_image(entry), language=language, raw_guid=_first_text(entry, "id"), ) @@ -411,6 +412,27 @@ def _atom_link(entry: ET.Element) -> str | None: return fallback +_IMG_SRC_RE = re.compile(r"""]*?\bsrc=["']([^"']+)["']""", re.IGNORECASE) + + +def _img_from_html(html: str | None) -> str | None: + """First in a content/description HTML blob, if any.""" + if not html: + return None + match = _IMG_SRC_RE.search(html) + return match.group(1) if match else None + + +def _html_image(element: ET.Element) -> str | None: + """Opportunistic image from the feed's content/description HTML. + + Only ever reads what the feed already provides — never fetches the article + page. A non-http(s)/relative URL is dropped by canonicalize_url. + """ + html = _first_text(element, "encoded", "content", "description", "summary") + return canonicalize_url(_img_from_html(html)) + + def _find_image_url(element: ET.Element) -> str | None: for child in element.iter(): name = _local_name(child.tag) diff --git a/tests/test_feed_images.py b/tests/test_feed_images.py new file mode 100644 index 0000000..0bafaa1 --- /dev/null +++ b/tests/test_feed_images.py @@ -0,0 +1,17 @@ +from goodnews.feeds import _img_from_html, parse_feed + +def test_img_from_html_finds_first_src(): + assert _img_from_html('

hi

') == "https://x.com/a.jpg" + assert _img_from_html("no images here") is None + assert _img_from_html(None) is None + +RSS = b""" +Storyhttps://e.com/1 +lead

more]]>
+NoImghttps://e.com/2just text +
""" + +def test_parse_feed_pulls_image_from_content_html(): + items = parse_feed(RSS) + assert items[0].image_url == "https://e.com/photo.jpg" + assert items[1].image_url is None # opportunistic: none when absent