diff --git a/README.md b/README.md index e87956a2..3151bc29 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/app/api/routes/news.py b/backend/app/api/routes/news.py new file mode 100644 index 00000000..4e49e073 --- /dev/null +++ b/backend/app/api/routes/news.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 16154358..601bdfac 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/frontend/src/components/layout/NewsModal.css b/frontend/src/components/layout/NewsModal.css new file mode 100644 index 00000000..674da64a --- /dev/null +++ b/frontend/src/components/layout/NewsModal.css @@ -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); +} diff --git a/frontend/src/components/layout/NewsModal.tsx b/frontend/src/components/layout/NewsModal.tsx new file mode 100644 index 00000000..c51a7116 --- /dev/null +++ b/frontend/src/components/layout/NewsModal.tsx @@ -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(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( +
setPost(null)}> +
e.stopPropagation()} + > +
+
+ What's new +

{post.title}

+
+ +
+
+ {post.body_md} +
+
+ +
+
+
, + document.body, + ); +} diff --git a/frontend/src/lib/newsSource.ts b/frontend/src/lib/newsSource.ts new file mode 100644 index 00000000..e93b34d3 --- /dev/null +++ b/frontend/src/lib/newsSource.ts @@ -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; + +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 { + 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 { + 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 { + try { + return _source ? await _source() : await ossFeedNext(); + } catch (err) { + // eslint-disable-next-line no-console + console.debug('[oss] news source failed:', err); + return null; + } +} diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 4d6d1057..5c0e03fe 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -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} /> )} + {/* Slot reserved for the private pro overlay (e.g. agent chat panel). Self-hosted builds without an overlay see nothing here. */}