feat(news): rich media in posts - inline images/GIFs + YouTube click-to-play previews

NewsMarkdown (shared renderer): YouTube links render as a thumbnail +
play badge that swaps to the youtube-nocookie iframe on click (nothing
loads from the player until the user opts in); images render inline;
all links open in a new tab. Product decision supersedes the alt-text
image stripping: posts embed screenshots/demos, and the README now
documents that embedded media loads in the viewer's browser.
This commit is contained in:
David Montero Crespo 2026-08-05 16:25:08 +02:00
parent 6205e32741
commit 64deb204ae
5 changed files with 144 additions and 23 deletions

View File

@ -345,7 +345,7 @@ change the CORS origin used during local development.
| Variable | Default | Description |
| --- | --- | --- |
| `FRONTEND_URL` | `http://localhost:5173` | Origin allowed by CORS for local Vite dev |
| `VELXIO_NEWS` | `on` | Product news: the backend fetches release notes and product news from velxio.dev's public feed (cached ~6h, anonymous, no identifiers sent) and the editor shows each item once as a "What's New" modal. Set to `off` to disable entirely; an offline host degrades to no news automatically. |
| `VELXIO_NEWS` | `on` | Product news: the backend fetches release notes and product news from velxio.dev's public feed (cached ~6h, anonymous, no identifiers sent) and the editor shows each item once as a "What's New" modal. Posts may embed remote media (screenshots, GIFs, YouTube previews) which the viewer's browser loads directly from the hosting site; video players only load after an explicit click. Set to `off` to disable entirely; an offline host degrades to no news automatically. |
> **Deploying behind a reverse proxy?** The container listens on plain HTTP
> on port 80 and accepts any `Host` header — no `server_name` whitelist.

View File

@ -4,12 +4,15 @@ The editor shows a once-per-browser "What's New" modal (see
frontend/src/lib/newsSource.ts). Rather than every user's browser
fetching velxio.dev directly, the backend proxies the public feed:
- users' browsers only ever talk to this server;
- one cached fetch per instance instead of one per visitor;
- the FEED fetch never leaves this server (one cached fetch per
instance instead of one per visitor, no identifiers sent) — though
media embedded in a post (images/GIFs, YouTube thumbnails) does
load in the viewer's browser from wherever it's hosted, as the
README documents;
- a single, documented opt-out: VELXIO_NEWS=off (also the automatic
behavior when the host has no internet — errors degrade to []).
The upstream feed is plain public JSON (no auth, no identifiers sent).
The upstream feed is plain public JSON (no auth).
"""
from __future__ import annotations

View File

@ -0,0 +1,84 @@
/**
* Markdown renderer for product-news content ("What's New" modal and the
* admin authoring preview — keep both on this one component so what the
* admin previews is exactly what users see).
*
* On top of plain GFM:
* - Any YouTube link (watch/shorts/youtu.be/embed) renders as a
* click-to-play preview: thumbnail + play badge, swapped for the
* youtube-nocookie iframe on click. Nothing loads from YouTube's
* player until the user opts in; the thumbnail is a static image.
* - Images (and GIFs) render inline. Deliberate product decision:
* news posts embed screenshots/demos, so viewers' browsers DO fetch
* remote media from the posts — documented in the OSS README next
* to the VELXIO_NEWS opt-out.
* - All links open in a new tab (the modal sits on top of the editor;
* navigating away would lose workspace state).
*/
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
function youTubeId(href: string): string | null {
const m = href.match(
/^https?:\/\/(?:www\.|m\.)?(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/)|youtu\.be\/)([\w-]{11})/,
);
return m ? m[1] : null;
}
function YouTubePreview({ id }: { id: string }) {
const [playing, setPlaying] = useState(false);
if (playing) {
return (
<span className="velxio-news-video">
<iframe
src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1`}
title="YouTube video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</span>
);
}
return (
<button
type="button"
className="velxio-news-video velxio-news-video-thumb"
onClick={() => setPlaying(true)}
aria-label="Play video"
>
<img src={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`} alt="Video preview" loading="lazy" />
<span className="velxio-news-play">▶</span>
</button>
);
}
export function NewsMarkdown({ children }: { children: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children: kids }) => {
const id = href ? youTubeId(href) : null;
if (id) {
const text = Array.isArray(kids) ? kids.join('') : String(kids ?? '');
return (
<>
<YouTubePreview id={id} />
{text && text !== href && (
<a href={href} target="_blank" rel="noreferrer">{kids}</a>
)}
</>
);
}
return (
<a href={href} target="_blank" rel="noreferrer">{kids}</a>
);
},
}}
>
{children}
</ReactMarkdown>
);
}

View File

@ -145,6 +145,57 @@
border-radius: 8px;
}
.velxio-news-video {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border: none;
border-radius: 8px;
overflow: hidden;
margin: 4px 0 10px;
padding: 0;
background: #000;
cursor: pointer;
}
.velxio-news-video iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
}
.velxio-news-video-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0;
display: block;
}
.velxio-news-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 22px;
display: flex;
align-items: center;
justify-content: center;
padding-left: 4px;
transition: background 0.15s;
}
.velxio-news-video-thumb:hover .velxio-news-play {
background: #e62117;
}
.velxio-news-body table {
border-collapse: collapse;
margin: 0 0 10px;

View File

@ -10,20 +10,12 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { getNextNews, type NewsPost } from '../../lib/newsSource';
import { NewsMarkdown } from './NewsMarkdown';
import './NewsModal.css';
const FETCH_DELAY_MS = 4000;
// Self-hosted privacy promise: the feed is proxied through the local
// backend precisely so users' browsers never contact velxio.dev — an
// inline remote image would undo that (one request per viewer). OSS
// builds therefore render images as their alt text; the pro cloud build
// (VITE_PRO_BUILD) shows them normally.
const SHOW_REMOTE_IMAGES = import.meta.env.VITE_PRO_BUILD === 'true';
export function NewsModal() {
const [post, setPost] = useState<NewsPost | null>(null);
@ -65,16 +57,7 @@ export function NewsModal() {
</button>
</div>
<div className="velxio-news-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={
SHOW_REMOTE_IMAGES
? undefined
: { img: ({ alt }) => <em>[{alt || 'image'}]</em> }
}
>
{post.body_md}
</ReactMarkdown>
<NewsMarkdown>{post.body_md}</NewsMarkdown>
</div>
<div className="velxio-news-footer">
<button className="velxio-news-ok" onClick={() => setPost(null)}>