feat(news): What's New modal - product news feed, once-per-user queue
Editor shows scheduled product-news posts as a markdown modal: max one per day, oldest first, each post only ever once (marked seen at delivery). OSS default source proxies velxio.dev's public feed through the local backend (cached ~6h, anonymous, VELXIO_NEWS=off to disable) with seen-state in localStorage; a pro overlay can register a server-backed per-user source via lib/newsSource.ts.
This commit is contained in:
parent
c9fe6f199d
commit
801bb94eab
|
|
@ -345,6 +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. |
|
||||
|
||||
> **Deploying behind a reverse proxy?** The container listens on plain HTTP
|
||||
> on port 80 and accepts any `Host` header — no `server_name` whitelist.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
"""Product-news feed proxy for self-hosted installs.
|
||||
|
||||
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;
|
||||
- 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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DEFAULT_FEED_URL = "https://velxio.dev/api/pro/news/feed"
|
||||
_CACHE_TTL_SECONDS = 6 * 60 * 60
|
||||
|
||||
_cache: dict = {"at": 0.0, "posts": None}
|
||||
|
||||
|
||||
def _news_enabled() -> bool:
|
||||
return os.environ.get("VELXIO_NEWS", "on").strip().lower() not in (
|
||||
"off", "0", "false", "disabled", "no",
|
||||
)
|
||||
|
||||
|
||||
def _sanitize(raw: object) -> list[dict]:
|
||||
"""Keep only the expected shape/fields — the payload gets rendered
|
||||
client-side, so an upstream surprise must not leak arbitrary junk."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
posts: list[dict] = []
|
||||
for item in raw[:50]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
posts.append({
|
||||
"id": str(item["id"])[:64],
|
||||
"title": str(item["title"])[:200],
|
||||
"body_md": str(item["body_md"])[:20_000],
|
||||
"publish_date": str(item["publish_date"])[:10],
|
||||
"expires_date": (
|
||||
str(item["expires_date"])[:10]
|
||||
if item.get("expires_date") else None
|
||||
),
|
||||
})
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
return posts
|
||||
|
||||
|
||||
@router.get("/feed")
|
||||
async def news_feed() -> list[dict]:
|
||||
"""Cached proxy of the velxio.dev product-news feed.
|
||||
|
||||
Returns [] when news is disabled, the host is offline, or upstream
|
||||
misbehaves — the modal simply never appears.
|
||||
"""
|
||||
if not _news_enabled():
|
||||
return []
|
||||
|
||||
now = time.monotonic()
|
||||
if _cache["posts"] is not None and now - _cache["at"] < _CACHE_TTL_SECONDS:
|
||||
return _cache["posts"]
|
||||
|
||||
url = os.environ.get("VELXIO_NEWS_URL", DEFAULT_FEED_URL)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
posts = _sanitize(resp.json())
|
||||
except Exception as exc: # offline / DNS / upstream down — all fine
|
||||
logger.debug("[news] feed fetch failed (%s); serving stale/empty", exc)
|
||||
return _cache["posts"] or []
|
||||
|
||||
_cache["posts"] = posts
|
||||
_cache["at"] = now
|
||||
return posts
|
||||
|
|
@ -115,6 +115,13 @@ app.include_router(simulation.router, prefix="/api/simulation", tags=["simulatio
|
|||
from app.api.routes import iot_gateway
|
||||
app.include_router(iot_gateway.router, prefix="/api/gateway", tags=["iot-gateway"])
|
||||
|
||||
# Product news ("What's New" modal) — cached proxy of velxio.dev's public
|
||||
# feed so self-hosted users get product news without their browsers ever
|
||||
# leaving this server. Anonymous, no identifiers sent; opt out with
|
||||
# VELXIO_NEWS=off (an offline host just serves an empty feed).
|
||||
from app.api.routes import news
|
||||
app.include_router(news.router, prefix="/api/news", tags=["news"])
|
||||
|
||||
# Optional pro extension. The `app.pro` package only exists in private builds
|
||||
# (overlaid at Docker build time by an external repo) — its absence in the
|
||||
# open-source image is expected and silently ignored. Anyone with private
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
.velxio-news-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
animation: velxio-news-fade 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes velxio-news-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.velxio-news-modal {
|
||||
background: #1e2228;
|
||||
border: 1px solid #3c4049;
|
||||
border-radius: 12px;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: min(80vh, 640px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
|
||||
animation: velxio-news-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes velxio-news-in {
|
||||
from { opacity: 0; transform: translateY(16px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.velxio-news-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 20px 12px;
|
||||
border-bottom: 1px solid #2b3038;
|
||||
}
|
||||
|
||||
.velxio-news-kicker {
|
||||
display: block;
|
||||
color: #4fc3f7;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.velxio-news-title {
|
||||
color: #e1e4e8;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.velxio-news-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.velxio-news-close:hover {
|
||||
color: #e1e4e8;
|
||||
background: #2b3038;
|
||||
}
|
||||
|
||||
.velxio-news-body {
|
||||
padding: 14px 20px;
|
||||
overflow-y: auto;
|
||||
color: #c9d1d9;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.velxio-news-body h1,
|
||||
.velxio-news-body h2,
|
||||
.velxio-news-body h3 {
|
||||
color: #e1e4e8;
|
||||
margin: 14px 0 6px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.velxio-news-body h1 { font-size: 17px; }
|
||||
.velxio-news-body h2 { font-size: 15px; }
|
||||
.velxio-news-body h3 { font-size: 14px; }
|
||||
|
||||
.velxio-news-body p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.velxio-news-body ul,
|
||||
.velxio-news-body ol {
|
||||
margin: 0 0 10px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.velxio-news-body li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.velxio-news-body a {
|
||||
color: #4fc3f7;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.velxio-news-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.velxio-news-body code {
|
||||
background: #2b3038;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-size: 12.5px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.velxio-news-body pre {
|
||||
background: #14171c;
|
||||
border: 1px solid #2b3038;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.velxio-news-body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.velxio-news-body img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.velxio-news-body table {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.velxio-news-body th,
|
||||
.velxio-news-body td {
|
||||
border: 1px solid #3c4049;
|
||||
padding: 5px 10px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.velxio-news-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid #2b3038;
|
||||
}
|
||||
|
||||
.velxio-news-ok {
|
||||
background: #4fc3f7;
|
||||
color: #10131a;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.velxio-news-ok:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* "What's New" modal — shows the next pending product-news post when the
|
||||
* user enters the editor. At most one per day, each post only ever once;
|
||||
* the queue/seen logic lives in lib/newsSource.ts (OSS: localStorage over
|
||||
* the local /api/news/feed proxy; pro overlay: server-backed per-user).
|
||||
*
|
||||
* The fetch is delayed a few seconds so it never competes with editor
|
||||
* startup, and every failure path resolves to "no modal".
|
||||
*/
|
||||
|
||||
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 './NewsModal.css';
|
||||
|
||||
const FETCH_DELAY_MS = 4000;
|
||||
|
||||
export function NewsModal() {
|
||||
const [post, setPost] = useState<NewsPost | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
void getNextNews().then((p) => {
|
||||
if (!cancelled && p) setPost(p);
|
||||
});
|
||||
}, FETCH_DELAY_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!post) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="velxio-news-overlay" onClick={() => setPost(null)}>
|
||||
<div
|
||||
className="velxio-news-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={post.title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="velxio-news-header">
|
||||
<div>
|
||||
<span className="velxio-news-kicker">What's new</span>
|
||||
<h2 className="velxio-news-title">{post.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="velxio-news-close"
|
||||
aria-label="Close"
|
||||
onClick={() => setPost(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="velxio-news-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body_md}</ReactMarkdown>
|
||||
</div>
|
||||
<div className="velxio-news-footer">
|
||||
<button className="velxio-news-ok" onClick={() => setPost(null)}>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Product-news source registry ("What's New" modal).
|
||||
*
|
||||
* Same seam pattern as proSession.ts: OSS ships a default source and the
|
||||
* pro overlay may replace it.
|
||||
*
|
||||
* - OSS default: fetch the local backend's /api/news/feed (a cached,
|
||||
* opt-out-able proxy of velxio.dev's public feed — see
|
||||
* backend/app/api/routes/news.py) and run the delivery queue in the
|
||||
* browser, with seen-state in localStorage. An anonymous install has
|
||||
* no accounts, so "once per user" means "once per browser" here.
|
||||
* - Pro overlay: registers a server-backed source (GET /api/pro/news/next)
|
||||
* where the queue and per-user seen tracking live in the database.
|
||||
*
|
||||
* Queue semantics (identical in both sources):
|
||||
* - only posts with publish_date <= today and not expired;
|
||||
* - at most one post per calendar day;
|
||||
* - oldest publish_date first;
|
||||
* - a post is marked seen when SERVED, never repeated.
|
||||
*/
|
||||
|
||||
import { getApiBase } from './apiBase';
|
||||
|
||||
export interface NewsPost {
|
||||
id: string;
|
||||
title: string;
|
||||
body_md: string;
|
||||
publish_date: string; // YYYY-MM-DD
|
||||
expires_date?: string | null;
|
||||
}
|
||||
|
||||
type NewsSource = () => Promise<NewsPost | null>;
|
||||
|
||||
let _source: NewsSource | null = null;
|
||||
|
||||
export function registerNewsSource(source: NewsSource): void {
|
||||
_source = source;
|
||||
}
|
||||
|
||||
const SEEN_KEY = 'velxio_news_seen';
|
||||
const LAST_SHOWN_KEY = 'velxio_news_last_shown';
|
||||
|
||||
function localToday(): string {
|
||||
const d = new Date();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function readSeen(): Set<string> {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(SEEN_KEY) ?? '[]');
|
||||
return new Set(Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
async function ossFeedNext(): Promise<NewsPost | null> {
|
||||
const today = localToday();
|
||||
// Max one post per day — bail before even fetching.
|
||||
if (localStorage.getItem(LAST_SHOWN_KEY) === today) return null;
|
||||
|
||||
const resp = await fetch(`${getApiBase()}/news/feed`);
|
||||
if (!resp.ok) return null;
|
||||
const posts: NewsPost[] = await resp.json();
|
||||
if (!Array.isArray(posts)) return null;
|
||||
|
||||
const seen = readSeen();
|
||||
const pending = posts
|
||||
.filter(
|
||||
(p) =>
|
||||
p && typeof p.id === 'string' && !seen.has(p.id) &&
|
||||
p.publish_date <= today &&
|
||||
(!p.expires_date || p.expires_date >= today),
|
||||
)
|
||||
.sort((a, b) => a.publish_date.localeCompare(b.publish_date));
|
||||
const post = pending[0];
|
||||
if (!post) return null;
|
||||
|
||||
// Mark seen at delivery time so a refresh can't show it twice.
|
||||
seen.add(post.id);
|
||||
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen]));
|
||||
localStorage.setItem(LAST_SHOWN_KEY, today);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the next news post to show, or null. Never throws — news is
|
||||
* strictly best-effort and must not disturb the editor.
|
||||
*/
|
||||
export async function getNextNews(): Promise<NewsPost | null> {
|
||||
try {
|
||||
return _source ? await _source() : await ossFeedNext();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[oss] news source failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import { Oscilloscope } from '../components/simulator/Oscilloscope';
|
|||
import { AppHeader } from '../components/layout/AppHeader';
|
||||
import { triggerSaveAction } from '../lib/proSaveAction';
|
||||
import { GitHubStarBanner } from '../components/layout/GitHubStarBanner';
|
||||
import { NewsModal } from '../components/layout/NewsModal';
|
||||
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
|
||||
import { useEditorStore } from '../store/useEditorStore';
|
||||
import { useCompileLogsStore } from '../store/useCompileLogsStore';
|
||||
|
|
@ -748,6 +749,7 @@ export const EditorPage: React.FC = () => {
|
|||
round={starRound}
|
||||
/>
|
||||
)}
|
||||
<NewsModal />
|
||||
{/* Slot reserved for the private pro overlay (e.g. agent chat panel).
|
||||
Self-hosted builds without an overlay see nothing here. */}
|
||||
<div data-velxio-slot="agent-chat" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue