feat(changelog): add changelog feed support and settings integration
Introduce changelog feed manifest support, including client and shared utilities for fetching and parsing changelog data. Add a new Settings modal panel for viewing changelog entries, with version detection based on the current app version. Expose a configurable changelog feed URL in both environment variables and admin panel runtime settings. Update documentation and deployment workflow to support changelog feed generation and consumption. Include unit tests for changelog utilities.
This commit is contained in:
parent
f300dfb01f
commit
9e09f80067
14 changed files with 687 additions and 6 deletions
|
|
@ -92,6 +92,7 @@ FFMPEG_BIN=
|
|||
# NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=true
|
||||
# NEXT_PUBLIC_RESTRICT_USER_API_KEYS=true
|
||||
# NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai
|
||||
# NEXT_PUBLIC_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json
|
||||
# NEXT_PUBLIC_DEFAULT_TTS_MODEL=kokoro
|
||||
# NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true
|
||||
# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false
|
||||
|
|
|
|||
15
.github/workflows/docs-deploy.yml
vendored
15
.github/workflows/docs-deploy.yml
vendored
|
|
@ -3,10 +3,18 @@ name: Docs Deploy
|
|||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published, edited, deleted]
|
||||
workflow_run:
|
||||
workflows: ["Version Docs on Tag"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full_reconcile:
|
||||
description: 'Run full changelog reconcile from GitHub Releases'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -43,6 +51,13 @@ jobs:
|
|||
- name: Install docs dependencies
|
||||
run: pnpm --dir docs-site install --frozen-lockfile
|
||||
|
||||
- name: Build changelog feed
|
||||
env:
|
||||
CHANGELOG_PUBLIC_BASE_URL: https://docs.openreader.richardr.dev
|
||||
CHANGELOG_FORCE_FULL: ${{ github.event_name == 'workflow_dispatch' && inputs.full_reconcile && '1' || '0' }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/build-changelog-feed.mjs
|
||||
|
||||
- name: Build docs
|
||||
run: pnpm --dir docs-site build
|
||||
|
||||
|
|
|
|||
1
docs-site/.gitignore
vendored
1
docs-site/.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
.docusaurus
|
||||
build
|
||||
node_modules
|
||||
static/changelog
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ Runtime-editable settings, one row per key:
|
|||
| Key | What it controls |
|
||||
| --- | --- |
|
||||
| `defaultTtsProvider` | Default provider id new users start with (built-in id or shared slug). |
|
||||
| `changelogFeedUrl` | Public changelog manifest URL used by the Settings modal changelog panel. |
|
||||
| `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. |
|
||||
| `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. |
|
||||
| `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). |
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
|
|||
| `API_BASE` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers |
|
||||
| `API_KEY` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers |
|
||||
| `NEXT_PUBLIC_*` runtime seeds | Legacy bootstrap seed | varies | Optional first-boot seeds for site features; then manage in Settings → Admin → Site features |
|
||||
| `NEXT_PUBLIC_CHANGELOG_FEED_URL` | Legacy bootstrap seed | `https://docs.openreader.richardr.dev/changelog/manifest.json` | Optional first-boot seed for changelog feed URL; then manage in Settings → Admin → Site features |
|
||||
| `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size |
|
||||
| `TTS_CACHE_TTL_MS` | TTS caching | `1800000` (30 min) | Tune in-memory TTS cache TTL |
|
||||
| `TTS_MAX_RETRIES` | TTS retry | `2` | Tune retry attempts for upstream 429/5xx |
|
||||
|
|
@ -405,6 +406,14 @@ Sets the default TTS provider for new users.
|
|||
|
||||
`showAllProviderModels` is a runtime-only admin setting (no env seed). Configure it in **Settings → Admin → Site features**.
|
||||
|
||||
### NEXT_PUBLIC_CHANGELOG_FEED_URL
|
||||
|
||||
Sets the changelog manifest URL used by the Settings modal changelog viewer.
|
||||
|
||||
- Default: `https://docs.openreader.richardr.dev/changelog/manifest.json`
|
||||
- Use this in self-hosted deployments when you publish changelog feeds to a custom docs domain/path.
|
||||
- Runtime key: `changelogFeedUrl`
|
||||
|
||||
|
||||
### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT
|
||||
|
||||
|
|
|
|||
225
scripts/build-changelog-feed.mjs
Normal file
225
scripts/build-changelog-feed.mjs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
#!/usr/bin/env node
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const REPO = process.env.CHANGELOG_REPO || process.env.GITHUB_REPOSITORY || 'richardr1126/openreader';
|
||||
const PUBLIC_BASE = (process.env.CHANGELOG_PUBLIC_BASE_URL || 'https://docs.openreader.richardr.dev').replace(/\/$/, '');
|
||||
const OUTPUT_DIR = path.resolve('docs-site/static/changelog');
|
||||
const RELEASES_DIR = path.join(OUTPUT_DIR, 'releases');
|
||||
const MUTABLE_COUNT = Number(process.env.CHANGELOG_MUTABLE_COUNT || '3');
|
||||
const FULL_MODE = process.argv.includes('--full') || process.env.CHANGELOG_FORCE_FULL === '1';
|
||||
|
||||
function normalizeTagSlug(tagName) {
|
||||
const normalized = String(tagName || '').trim().toLowerCase();
|
||||
const collapsed = normalized
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
return collapsed || 'release';
|
||||
}
|
||||
|
||||
function sortEntries(entries) {
|
||||
return [...entries].sort((a, b) => {
|
||||
const aMs = Date.parse(a.published_at);
|
||||
const bMs = Date.parse(b.published_at);
|
||||
if (Number.isFinite(aMs) && Number.isFinite(bMs) && aMs !== bMs) {
|
||||
return bMs - aMs;
|
||||
}
|
||||
return String(b.tag_name || '').localeCompare(String(a.tag_name || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function toManifestEntry(release) {
|
||||
return {
|
||||
tag_name: String(release.tag_name || ''),
|
||||
name: String(release.name || release.tag_name || ''),
|
||||
published_at: String(release.published_at || release.created_at || new Date().toISOString()),
|
||||
html_url: String(release.html_url || ''),
|
||||
prerelease: Boolean(release.prerelease),
|
||||
body_path: '',
|
||||
};
|
||||
}
|
||||
|
||||
function toBodyRecord(release) {
|
||||
return {
|
||||
tag_name: String(release.tag_name || ''),
|
||||
name: String(release.name || release.tag_name || ''),
|
||||
published_at: String(release.published_at || release.created_at || new Date().toISOString()),
|
||||
html_url: String(release.html_url || ''),
|
||||
prerelease: Boolean(release.prerelease),
|
||||
body: String(release.body || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function applyBodyPath(entries) {
|
||||
const seen = new Map();
|
||||
return entries.map((entry) => {
|
||||
const base = normalizeTagSlug(entry.tag_name);
|
||||
const count = seen.get(base) ?? 0;
|
||||
seen.set(base, count + 1);
|
||||
const slug = count === 0 ? base : `${base}-${count + 1}`;
|
||||
return { ...entry, body_path: `changelog/releases/${slug}.json` };
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson(url, { auth = false } = {}) {
|
||||
const headers = { Accept: 'application/vnd.github+json' };
|
||||
if (auth && process.env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Fetch failed ${res.status} for ${url}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchGitHubReleases() {
|
||||
const out = [];
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const url = `https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`;
|
||||
const data = await fetchJson(url, { auth: true });
|
||||
if (!Array.isArray(data) || data.length === 0) break;
|
||||
out.push(...data);
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readEventPayload() {
|
||||
const eventPath = process.env.GITHUB_EVENT_PATH;
|
||||
if (!eventPath) return null;
|
||||
try {
|
||||
const raw = await readFile(eventPath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemoteManifest() {
|
||||
const url = `${PUBLIC_BASE}/changelog/manifest.json`;
|
||||
try {
|
||||
const data = await fetchJson(url);
|
||||
if (!Array.isArray(data?.releases)) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemoteBody(bodyPath) {
|
||||
const url = `${PUBLIC_BASE}/${bodyPath.replace(/^\/+/, '')}`;
|
||||
try {
|
||||
return await fetchJson(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRemoteState() {
|
||||
const manifestDoc = await fetchRemoteManifest();
|
||||
if (!manifestDoc) return null;
|
||||
const manifest = Array.isArray(manifestDoc.releases) ? manifestDoc.releases : [];
|
||||
const bodies = new Map();
|
||||
await Promise.all(manifest.map(async (entry) => {
|
||||
if (!entry?.body_path) return;
|
||||
const bodyDoc = await fetchRemoteBody(entry.body_path);
|
||||
if (bodyDoc && typeof bodyDoc.body === 'string') {
|
||||
bodies.set(entry.tag_name, bodyDoc);
|
||||
}
|
||||
}));
|
||||
return { manifest, bodies };
|
||||
}
|
||||
|
||||
function isMutable(manifest, tagName) {
|
||||
const idx = manifest.findIndex((x) => x.tag_name === tagName);
|
||||
return idx >= 0 && idx < MUTABLE_COUNT;
|
||||
}
|
||||
|
||||
async function buildState() {
|
||||
const eventName = process.env.GITHUB_EVENT_NAME || '';
|
||||
const payload = await readEventPayload();
|
||||
const releaseEvent = payload?.release;
|
||||
const releaseAction = payload?.action || '';
|
||||
|
||||
const remoteState = await loadRemoteState();
|
||||
const shouldFull = FULL_MODE || !remoteState;
|
||||
|
||||
if (shouldFull) {
|
||||
const releases = await fetchGitHubReleases();
|
||||
const filtered = releases.filter((r) => !r.draft);
|
||||
const entries = sortEntries(filtered.map(toManifestEntry));
|
||||
const entriesWithPath = applyBodyPath(entries);
|
||||
const bodies = new Map(filtered.map((r) => [r.tag_name, toBodyRecord(r)]));
|
||||
return { entries: entriesWithPath, bodies, mode: 'full' };
|
||||
}
|
||||
|
||||
const manifest = sortEntries(remoteState.manifest.filter((x) => !!x?.tag_name));
|
||||
const bodies = new Map(remoteState.bodies);
|
||||
|
||||
if (eventName === 'release' && releaseEvent?.tag_name) {
|
||||
const tagName = String(releaseEvent.tag_name);
|
||||
const isDraft = Boolean(releaseEvent.draft);
|
||||
const mutable = isMutable(manifest, tagName);
|
||||
|
||||
if (releaseAction === 'deleted') {
|
||||
if (mutable) {
|
||||
const next = manifest.filter((entry) => entry.tag_name !== tagName);
|
||||
bodies.delete(tagName);
|
||||
return { entries: applyBodyPath(sortEntries(next)), bodies, mode: 'incremental-delete' };
|
||||
}
|
||||
return { entries: applyBodyPath(manifest), bodies, mode: 'incremental-delete-skipped' };
|
||||
}
|
||||
|
||||
if (!isDraft) {
|
||||
const incoming = toManifestEntry(releaseEvent);
|
||||
const existingIdx = manifest.findIndex((entry) => entry.tag_name === tagName);
|
||||
if (existingIdx === -1 || mutable || existingIdx < MUTABLE_COUNT) {
|
||||
if (existingIdx >= 0) manifest.splice(existingIdx, 1);
|
||||
manifest.push(incoming);
|
||||
bodies.set(tagName, toBodyRecord(releaseEvent));
|
||||
}
|
||||
}
|
||||
|
||||
return { entries: applyBodyPath(sortEntries(manifest)), bodies, mode: 'incremental-upsert' };
|
||||
}
|
||||
|
||||
return { entries: applyBodyPath(manifest), bodies, mode: 'mirror' };
|
||||
}
|
||||
|
||||
async function writeState({ entries, bodies, mode }) {
|
||||
await rm(OUTPUT_DIR, { recursive: true, force: true });
|
||||
await mkdir(RELEASES_DIR, { recursive: true });
|
||||
|
||||
const manifest = {
|
||||
generated_at: new Date().toISOString(),
|
||||
source: `https://github.com/${REPO}/releases`,
|
||||
mutable_window: MUTABLE_COUNT,
|
||||
mode,
|
||||
releases: entries,
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
let body = bodies.get(entry.tag_name);
|
||||
if (!body) {
|
||||
body = {
|
||||
tag_name: entry.tag_name,
|
||||
name: entry.name,
|
||||
published_at: entry.published_at,
|
||||
html_url: entry.html_url,
|
||||
prerelease: entry.prerelease,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
const outPath = path.resolve('docs-site/static', entry.body_path);
|
||||
await mkdir(path.dirname(outPath), { recursive: true });
|
||||
await writeFile(outPath, `${JSON.stringify(body, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
await writeFile(path.join(OUTPUT_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
console.log(`Wrote ${entries.length} changelog releases (${mode})`);
|
||||
}
|
||||
|
||||
const state = await buildState();
|
||||
await writeState(state);
|
||||
|
|
@ -5,6 +5,7 @@ import { Figtree } from "next/font/google";
|
|||
import { ConsentAwareAnalytics } from "@/components/ConsentAwareAnalytics";
|
||||
import { CookieConsentBanner } from "@/components/CookieConsentBanner";
|
||||
import { getResolvedRuntimeConfig } from "@/lib/server/runtime-config";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
const figtree = Figtree({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -51,7 +52,11 @@ function jsonEmbedSafe(value: unknown): string {
|
|||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||
const runtimeConfigInit = `window.__OPENREADER_RUNTIME_CONFIG__=${jsonEmbedSafe(runtimeConfig)};`;
|
||||
const runtimeConfigWithAppVersion = {
|
||||
...runtimeConfig,
|
||||
appVersion: pkg.version,
|
||||
};
|
||||
const runtimeConfigInit = `window.__OPENREADER_RUNTIME_CONFIG__=${jsonEmbedSafe(runtimeConfigWithAppVersion)};`;
|
||||
|
||||
return (
|
||||
<html lang="en" className={figtree.variable} suppressHydrationWarning>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,15 @@ import {
|
|||
segmentedButtonClass,
|
||||
segmentedGroupClass,
|
||||
} from '@/components/formPrimitives';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog';
|
||||
import {
|
||||
findCurrentVersionIndex,
|
||||
normalizeVersion,
|
||||
type ChangelogManifestEntry,
|
||||
type ChangelogReleaseBody,
|
||||
} from '@/lib/shared/changelog';
|
||||
|
||||
// Hard-coded theme color palettes for the visual theme selector
|
||||
type ThemeColorSet = { background: string; base: string; offbase: string; accent: string; secondaryAccent: string; foreground: string; muted: string };
|
||||
|
|
@ -131,6 +140,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
|
||||
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isChangelogOpen, setIsChangelogOpen] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(enableTTSProvidersTab ? 'api' : 'theme');
|
||||
|
||||
const { theme, setTheme, applyCustomColors } = useTheme();
|
||||
|
|
@ -410,6 +420,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
|
||||
const resetToCurrent = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setIsChangelogOpen(false);
|
||||
setLocalApiKey(apiKey);
|
||||
setLocalBaseUrl(baseUrl);
|
||||
setLocalProviderRef(providerRef);
|
||||
|
|
@ -501,6 +512,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
const selectedModelVersion = selectedModel?.id?.includes(':')
|
||||
? selectedModel.id.slice(selectedModel.id.indexOf(':'))
|
||||
: '';
|
||||
const displayVersion = normalizeVersion(runtimeConfig.appVersion || '');
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -538,22 +550,38 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel data-testid="settings-modal" className="w-full max-w-4xl transform rounded-xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden border border-offbase">
|
||||
<DialogPanel data-testid="settings-modal" className="relative w-full max-w-4xl transform rounded-xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden border border-offbase">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-offbase">
|
||||
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground">
|
||||
Settings
|
||||
</DialogTitle>
|
||||
{authEnabled && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
onClick={() => showPrivacyModal({ authEnabled })}
|
||||
onClick={() => setIsChangelogOpen(true)}
|
||||
className="text-sm font-medium text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
Privacy
|
||||
{displayVersion ? `v${displayVersion} · Changelog` : 'Changelog'}
|
||||
</Button>
|
||||
)}
|
||||
{authEnabled && (
|
||||
<Button
|
||||
onClick={() => showPrivacyModal({ authEnabled })}
|
||||
className="text-sm font-medium text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
Privacy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isChangelogOpen && (
|
||||
<SettingsChangelogPanel
|
||||
appVersion={runtimeConfig.appVersion}
|
||||
manifestUrl={runtimeConfig.changelogFeedUrl}
|
||||
onClose={() => setIsChangelogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile: 2x2 grid nav */}
|
||||
<div className="grid grid-cols-2 gap-1 sm:hidden border-b border-offbase bg-background p-2">
|
||||
{visibleSections.map((section) => {
|
||||
|
|
@ -1347,3 +1375,179 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsChangelogPanel({
|
||||
appVersion,
|
||||
manifestUrl,
|
||||
onClose,
|
||||
}: {
|
||||
appVersion: string;
|
||||
manifestUrl: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [manifest, setManifest] = useState<ChangelogManifestEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [bodies, setBodies] = useState<Record<string, ChangelogReleaseBody>>({});
|
||||
const normalizedAppVersion = normalizeVersion(appVersion || '');
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
async function loadManifest() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const entries = await fetchChangelogManifest(manifestUrl, controller.signal);
|
||||
setManifest(entries);
|
||||
const initialIndex = findCurrentVersionIndex(entries, normalizedAppVersion);
|
||||
if (initialIndex >= 0) {
|
||||
const entry = entries[initialIndex];
|
||||
setExpanded((prev) => ({ ...prev, [entry.tag_name]: true }));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load changelog');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
void loadManifest();
|
||||
return () => controller.abort();
|
||||
}, [manifestUrl, normalizedAppVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const tagsToLoad = manifest
|
||||
.filter((entry) => expanded[entry.tag_name] && !bodies[entry.tag_name])
|
||||
.map((entry) => entry.tag_name);
|
||||
if (tagsToLoad.length === 0) return () => controller.abort();
|
||||
|
||||
async function loadBodies() {
|
||||
await Promise.all(tagsToLoad.map(async (tag) => {
|
||||
const entry = manifest.find((item) => item.tag_name === tag);
|
||||
if (!entry) return;
|
||||
try {
|
||||
const body = await fetchChangelogReleaseBody(manifestUrl, entry.body_path, controller.signal);
|
||||
setBodies((prev) => ({ ...prev, [tag]: body }));
|
||||
} catch {
|
||||
// Keep entry expanded; inline fallback appears below.
|
||||
}
|
||||
}));
|
||||
}
|
||||
void loadBodies();
|
||||
return () => controller.abort();
|
||||
}, [expanded, manifest, manifestUrl, bodies]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-x-0 top-[65px] bottom-0 z-20 bg-base border-t border-offbase">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-offbase bg-background">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-semibold text-foreground">Changelog</h4>
|
||||
<p className="text-xs text-muted truncate">
|
||||
{normalizedAppVersion
|
||||
? `Current version: v${normalizedAppVersion}`
|
||||
: 'Release history from GitHub'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="text-sm font-medium text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
Back to settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-[calc(100%-57px)] overflow-y-auto p-3 space-y-2">
|
||||
{loading && (
|
||||
<div className="rounded-lg border border-offbase bg-background p-3 text-sm text-muted">
|
||||
Loading changelog…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="rounded-lg border border-offbase bg-background p-3 space-y-2">
|
||||
<p className="text-sm text-foreground">Could not load changelog right now.</p>
|
||||
<p className="text-xs text-muted break-words">{error}</p>
|
||||
<a
|
||||
href="https://github.com/richardr1126/openreader/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs font-medium text-accent hover:underline"
|
||||
>
|
||||
Open GitHub Releases
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && manifest.length === 0 && (
|
||||
<div className="rounded-lg border border-offbase bg-background p-3 text-sm text-muted">
|
||||
No releases found.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && manifest.map((entry) => {
|
||||
const isCurrent = normalizedAppVersion && normalizeVersion(entry.tag_name) === normalizedAppVersion;
|
||||
const body = bodies[entry.tag_name];
|
||||
const isExpanded = !!expanded[entry.tag_name];
|
||||
return (
|
||||
<div
|
||||
key={entry.tag_name}
|
||||
className={`rounded-lg border bg-background overflow-hidden ${
|
||||
isCurrent ? 'border-accent' : 'border-offbase'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => ({ ...prev, [entry.tag_name]: !isExpanded }))}
|
||||
className="w-full text-left px-3 py-2 flex items-center justify-between gap-3 hover:bg-base transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-foreground truncate">{entry.tag_name}</span>
|
||||
{entry.prerelease && (
|
||||
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-offbase text-muted">
|
||||
prerelease
|
||||
</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-offbase text-accent">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted truncate">{entry.name}</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
{new Date(entry.published_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted">{isExpanded ? 'Hide' : 'Show'}</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-offbase space-y-2">
|
||||
{body ? (
|
||||
<div className="text-sm text-foreground leading-6 space-y-2 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_ul]:pl-5 [&_ol]:pl-5 [&_code]:bg-offbase [&_code]:rounded [&_code]:px-1 [&_pre]:bg-offbase [&_pre]:rounded [&_pre]:p-2 [&_pre]:overflow-x-auto">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{body.body || '_No release notes provided._'}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted">Loading release notes…</p>
|
||||
)}
|
||||
<a
|
||||
href={entry.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs font-medium text-accent hover:underline"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ToggleRow,
|
||||
btnPrimary,
|
||||
btnSecondary,
|
||||
inputClass,
|
||||
listboxButtonClass,
|
||||
listboxOptionClass,
|
||||
listboxOptionsClass,
|
||||
|
|
@ -277,6 +278,24 @@ export function AdminFeaturesPanel() {
|
|||
subtitle="Feature flags for all users."
|
||||
action={<Badge tone="foreground">Feature Flags</Badge>}
|
||||
>
|
||||
<div className="space-y-1.5 pb-2 border-b border-offbase">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Changelog feed URL</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Public URL to the changelog manifest JSON used by Settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={String(draft.changelogFeedUrl ?? '')}
|
||||
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
|
||||
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
|
||||
/>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label="Word-level highlighting"
|
||||
description="Highlight words during TTS playback."
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|||
*/
|
||||
export interface RuntimeConfig {
|
||||
defaultTtsProvider: string;
|
||||
changelogFeedUrl: string;
|
||||
appVersion: string;
|
||||
restrictUserApiKeys: boolean;
|
||||
enableTtsProvidersTab: boolean;
|
||||
enableWordHighlight: boolean;
|
||||
|
|
@ -24,6 +26,8 @@ export interface RuntimeConfig {
|
|||
|
||||
const RUNTIME_DEFAULTS: RuntimeConfig = {
|
||||
defaultTtsProvider: 'custom-openai',
|
||||
changelogFeedUrl: 'https://docs.openreader.richardr.dev/changelog/manifest.json',
|
||||
appVersion: '0.0.0',
|
||||
restrictUserApiKeys: true,
|
||||
enableTtsProvidersTab: true,
|
||||
enableWordHighlight: true,
|
||||
|
|
|
|||
51
src/lib/client/changelog.ts
Normal file
51
src/lib/client/changelog.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { ChangelogManifestEntry, ChangelogReleaseBody } from '@/lib/shared/changelog';
|
||||
import { sortManifestEntries } from '@/lib/shared/changelog';
|
||||
|
||||
interface ManifestDocument {
|
||||
generated_at?: string;
|
||||
releases?: ChangelogManifestEntry[];
|
||||
}
|
||||
|
||||
export async function fetchChangelogManifest(url: string, signal?: AbortSignal): Promise<ChangelogManifestEntry[]> {
|
||||
const res = await fetch(url, {
|
||||
signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch changelog manifest: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as ManifestDocument;
|
||||
if (!Array.isArray(data.releases)) {
|
||||
throw new Error('Invalid changelog manifest format');
|
||||
}
|
||||
|
||||
const sanitized = data.releases.filter((entry) => (
|
||||
!!entry
|
||||
&& typeof entry.tag_name === 'string'
|
||||
&& typeof entry.name === 'string'
|
||||
&& typeof entry.published_at === 'string'
|
||||
&& typeof entry.html_url === 'string'
|
||||
&& typeof entry.body_path === 'string'
|
||||
&& typeof entry.prerelease === 'boolean'
|
||||
));
|
||||
|
||||
return sortManifestEntries(sanitized);
|
||||
}
|
||||
|
||||
export async function fetchChangelogReleaseBody(baseManifestUrl: string, bodyPath: string, signal?: AbortSignal): Promise<ChangelogReleaseBody> {
|
||||
const bodyUrl = new URL(bodyPath.replace(/^\/+/, ''), `${new URL(baseManifestUrl).origin}/`).toString();
|
||||
const res = await fetch(bodyUrl, {
|
||||
signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch changelog release body: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json() as ChangelogReleaseBody;
|
||||
if (typeof data.body !== 'string') {
|
||||
throw new Error('Invalid changelog release body format');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
|
@ -76,6 +76,7 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<
|
|||
|
||||
export const RUNTIME_CONFIG_SCHEMA = {
|
||||
defaultTtsProvider: stringValue('custom-openai', 'NEXT_PUBLIC_DEFAULT_TTS_PROVIDER'),
|
||||
changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'NEXT_PUBLIC_CHANGELOG_FEED_URL'),
|
||||
restrictUserApiKeys: booleanFlag(true, 'NEXT_PUBLIC_RESTRICT_USER_API_KEYS'),
|
||||
// Historically the env semantics were "true unless explicitly 'false'",
|
||||
// i.e. the feature defaults to ON.
|
||||
|
|
|
|||
52
src/lib/shared/changelog.ts
Normal file
52
src/lib/shared/changelog.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export interface ChangelogManifestEntry {
|
||||
tag_name: string;
|
||||
name: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
prerelease: boolean;
|
||||
body_path: string;
|
||||
}
|
||||
|
||||
export interface ChangelogReleaseBody extends ChangelogManifestEntry {
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function normalizeVersion(value: string): string {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.startsWith('v') ? trimmed.slice(1) : trimmed;
|
||||
}
|
||||
|
||||
export function tagsMatchVersion(tag: string, version: string): boolean {
|
||||
const left = normalizeVersion(tag);
|
||||
const right = normalizeVersion(version);
|
||||
return !!left && !!right && left === right;
|
||||
}
|
||||
|
||||
export function sortManifestEntries(entries: ChangelogManifestEntry[]): ChangelogManifestEntry[] {
|
||||
return [...entries].sort((a, b) => {
|
||||
const aMs = Date.parse(a.published_at);
|
||||
const bMs = Date.parse(b.published_at);
|
||||
if (Number.isFinite(aMs) && Number.isFinite(bMs) && aMs !== bMs) {
|
||||
return bMs - aMs;
|
||||
}
|
||||
return b.tag_name.localeCompare(a.tag_name);
|
||||
});
|
||||
}
|
||||
|
||||
export function findCurrentVersionIndex(entries: ChangelogManifestEntry[], appVersion: string): number {
|
||||
return entries.findIndex((entry) => tagsMatchVersion(entry.tag_name, appVersion));
|
||||
}
|
||||
|
||||
export function toSafeTagSlug(tagName: string): string {
|
||||
const normalized = tagName.trim().toLowerCase();
|
||||
const collapsed = normalized
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
return collapsed || 'release';
|
||||
}
|
||||
|
||||
export function isMutableIndex(index: number, mutableCount = 3): boolean {
|
||||
return index >= 0 && index < mutableCount;
|
||||
}
|
||||
93
tests/unit/changelog.spec.ts
Normal file
93
tests/unit/changelog.spec.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
findCurrentVersionIndex,
|
||||
isMutableIndex,
|
||||
normalizeVersion,
|
||||
sortManifestEntries,
|
||||
tagsMatchVersion,
|
||||
toSafeTagSlug,
|
||||
type ChangelogManifestEntry,
|
||||
} from '../../src/lib/shared/changelog';
|
||||
|
||||
test.describe('changelog utilities', () => {
|
||||
test('normalizes version strings with and without v prefix', () => {
|
||||
expect(normalizeVersion('v2.2.0')).toBe('2.2.0');
|
||||
expect(normalizeVersion('2.2.0')).toBe('2.2.0');
|
||||
expect(normalizeVersion(' V2.2.0 ')).toBe('2.2.0');
|
||||
});
|
||||
|
||||
test('matches tag and version reliably', () => {
|
||||
expect(tagsMatchVersion('v2.2.0', '2.2.0')).toBe(true);
|
||||
expect(tagsMatchVersion('2.2.0', 'v2.2.0')).toBe(true);
|
||||
expect(tagsMatchVersion('v2.2.1', '2.2.0')).toBe(false);
|
||||
});
|
||||
|
||||
test('finds the running version index in manifest entries', () => {
|
||||
const entries: ChangelogManifestEntry[] = [
|
||||
{
|
||||
tag_name: 'v2.3.0',
|
||||
name: 'v2.3.0',
|
||||
published_at: '2026-05-01T00:00:00.000Z',
|
||||
html_url: 'https://example.com/1',
|
||||
prerelease: false,
|
||||
body_path: 'changelog/releases/v2-3-0.json',
|
||||
},
|
||||
{
|
||||
tag_name: 'v2.2.0',
|
||||
name: 'v2.2.0',
|
||||
published_at: '2026-04-01T00:00:00.000Z',
|
||||
html_url: 'https://example.com/2',
|
||||
prerelease: false,
|
||||
body_path: 'changelog/releases/v2-2-0.json',
|
||||
},
|
||||
];
|
||||
|
||||
expect(findCurrentVersionIndex(entries, '2.2.0')).toBe(1);
|
||||
expect(findCurrentVersionIndex(entries, 'v2.3.0')).toBe(0);
|
||||
expect(findCurrentVersionIndex(entries, '1.0.0')).toBe(-1);
|
||||
});
|
||||
|
||||
test('sorts manifest newest first by published_at', () => {
|
||||
const entries: ChangelogManifestEntry[] = [
|
||||
{
|
||||
tag_name: 'v2.1.0',
|
||||
name: 'v2.1.0',
|
||||
published_at: '2026-03-01T00:00:00.000Z',
|
||||
html_url: 'https://example.com/1',
|
||||
prerelease: false,
|
||||
body_path: 'changelog/releases/v2-1-0.json',
|
||||
},
|
||||
{
|
||||
tag_name: 'v2.3.0',
|
||||
name: 'v2.3.0',
|
||||
published_at: '2026-05-01T00:00:00.000Z',
|
||||
html_url: 'https://example.com/3',
|
||||
prerelease: false,
|
||||
body_path: 'changelog/releases/v2-3-0.json',
|
||||
},
|
||||
{
|
||||
tag_name: 'v2.2.0',
|
||||
name: 'v2.2.0',
|
||||
published_at: '2026-04-01T00:00:00.000Z',
|
||||
html_url: 'https://example.com/2',
|
||||
prerelease: false,
|
||||
body_path: 'changelog/releases/v2-2-0.json',
|
||||
},
|
||||
];
|
||||
|
||||
expect(sortManifestEntries(entries).map((entry) => entry.tag_name)).toEqual(['v2.3.0', 'v2.2.0', 'v2.1.0']);
|
||||
});
|
||||
|
||||
test('treats only newest three entries as mutable by default', () => {
|
||||
expect(isMutableIndex(0)).toBe(true);
|
||||
expect(isMutableIndex(1)).toBe(true);
|
||||
expect(isMutableIndex(2)).toBe(true);
|
||||
expect(isMutableIndex(3)).toBe(false);
|
||||
});
|
||||
|
||||
test('builds filesystem-safe slugs for release tags', () => {
|
||||
expect(toSafeTagSlug('v2.2.0')).toBe('v2.2.0');
|
||||
expect(toSafeTagSlug('v2.2.0+build/meta')).toBe('v2.2.0-build-meta');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue