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:
parent
6205e32741
commit
64deb204ae
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue