feat(updates): "What's New" highlights after update + pre-update teaser
Surfaces release highlights to admins, sourced from the GitHub release notes (no AI at runtime). Bullets are written once per release in CI via GitHub Models (see docs/ci/whatsnew-highlights.yml) into a <!-- whatsnew --> block; the app reads that block and falls back to the changelog's "### Features" for releases without it — so it works against today's releases immediately. - backend utils/whatsNew.parseWhatsNew(body): curated block else Features section, strips scope/PR-links, de-dups, caps at 8 (tested). - GET /admin/system/updates/whatsnew: highlights for every version moved through since the per-instance marker (whatsnew_last_seen_version); fresh installs self-anchor silently. Best-effort, never errors. - POST /admin/system/updates/whatsnew/seen: advance the marker (per-instance). - /admin/system/updates also returns latestHighlights for the teaser. - Frontend: WhatsNewBanner (green bar -> modal with "Full changelog" link) on the dashboard via adminService; UpdateNotification shows a "New features include:" teaser. i18n de/en. No migration (uses app_settings).
This commit is contained in:
@@ -19,6 +19,8 @@ interface UpdateInfo {
|
||||
lastChecked: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
/** Top highlights of the target version (pre-update teaser). */
|
||||
latestHighlights?: string[];
|
||||
}
|
||||
|
||||
async function fetchUpdateInfo(): Promise<UpdateInfo> {
|
||||
@@ -81,6 +83,16 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
||||
channel: channelLabel
|
||||
})}
|
||||
</p>
|
||||
{Array.isArray(updateInfo.latestHighlights) && updateInfo.latestHighlights.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-blue-700 dark:text-blue-300">
|
||||
{t('admin.updates.newFeatures', 'New features include:')}
|
||||
</p>
|
||||
<ul className="text-xs text-blue-700 dark:text-blue-300 mt-0.5 list-disc list-inside">
|
||||
{updateInfo.latestHighlights.slice(0, 4).map((h, i) => <li key={i}>{h}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
onClick={() => setShowInstructions(true)}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* After-update "What's New" — a dismissible green bar that expands into a
|
||||
* modal. Driven by GET /admin/system/updates/whatsnew, which returns the
|
||||
* curated highlights for every version this instance moved through since it
|
||||
* last acknowledged one. Dismiss (X or "Got it") advances the per-instance
|
||||
* marker via POST .../seen, so it stops showing for everyone.
|
||||
*
|
||||
* Bullets are written once in the release CI (GitHub Models) and read from
|
||||
* the GitHub release notes — there's no AI at runtime. Releases without a
|
||||
* curated block fall back to their changelog "Features".
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sparkles, X, ExternalLink, ChevronRight } from 'lucide-react';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
|
||||
export const WhatsNewBanner: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['whatsnew'],
|
||||
queryFn: () => adminService.getWhatsNew(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const seen = useMutation({
|
||||
mutationFn: () => adminService.markWhatsNewSeen(),
|
||||
onSuccess: () => {
|
||||
setHidden(true);
|
||||
setOpen(false);
|
||||
qc.invalidateQueries({ queryKey: ['whatsnew'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (hidden || !data?.hasNews || !data.versions?.length) return null;
|
||||
|
||||
// Inline teaser on the bar: the first few bullets across all new versions.
|
||||
const teaser = data.versions.flatMap((v) => v.bullets).slice(0, 3);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-green-50 dark:bg-green-900/30 border-l-4 border-green-500 p-4 mb-4 rounded-r-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start">
|
||||
<Sparkles className="w-5 h-5 text-green-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-green-800 dark:text-green-200">
|
||||
{t('admin.whatsnew.title', "What's new in {{version}}", { version: data.toVersion })}
|
||||
</h4>
|
||||
<ul className="text-sm text-green-700 dark:text-green-300 mt-1 list-disc list-inside">
|
||||
{teaser.map((b, i) => <li key={i}>{b}</li>)}
|
||||
</ul>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center text-xs font-medium text-white bg-green-600 hover:bg-green-700 px-3 py-1.5 rounded-md transition-colors"
|
||||
>
|
||||
{t('admin.whatsnew.viewAll', "What's new")}
|
||||
<ChevronRight className="w-3 h-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => seen.mutate()}
|
||||
className="text-green-500 hover:text-green-700 dark:hover:text-green-300 p-1"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-neutral-800 rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-auto p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2 text-neutral-900 dark:text-neutral-100">
|
||||
<Sparkles className="w-5 h-5 text-green-600" />
|
||||
{t('admin.whatsnew.modalTitle', "What's new")}
|
||||
</h3>
|
||||
<button onClick={() => setOpen(false)} className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{data.versions.map((v) => (
|
||||
<div key={v.version}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{v.name || `v${v.version}`}</h4>
|
||||
<a
|
||||
href={v.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 dark:text-blue-400 inline-flex items-center whitespace-nowrap"
|
||||
>
|
||||
{t('admin.whatsnew.fullChangelog', 'Full changelog')}
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
<ul className="mt-1 list-disc list-inside text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{v.bullets.map((b, i) => <li key={i}>{b}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => seen.mutate()}
|
||||
className="text-sm font-medium text-white bg-green-600 hover:bg-green-700 px-4 py-2 rounded-md transition-colors"
|
||||
>
|
||||
{t('admin.whatsnew.gotIt', 'Got it')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2295,10 +2295,18 @@
|
||||
"warning": "Warnung",
|
||||
"error": "Fehler"
|
||||
},
|
||||
"whatsnew": {
|
||||
"title": "Neu in {{version}}",
|
||||
"viewAll": "Was ist neu",
|
||||
"modalTitle": "Was ist neu",
|
||||
"fullChangelog": "Vollständiges Änderungsprotokoll",
|
||||
"gotIt": "Verstanden"
|
||||
},
|
||||
"updates": {
|
||||
"available": "Update verfügbar",
|
||||
"newVersion": "Version {{version}} ist verfügbar",
|
||||
"currentVersion": "Aktuell: {{version}}",
|
||||
"newFeatures": "Neue Funktionen:",
|
||||
"channel": "Kanal: {{channel}}",
|
||||
"channelStable": "Stabil",
|
||||
"channelBeta": "Beta",
|
||||
|
||||
@@ -1880,10 +1880,18 @@
|
||||
"error": "Error",
|
||||
"checking": "Checking..."
|
||||
},
|
||||
"whatsnew": {
|
||||
"title": "What's new in {{version}}",
|
||||
"viewAll": "What's new",
|
||||
"modalTitle": "What's new",
|
||||
"fullChangelog": "Full changelog",
|
||||
"gotIt": "Got it"
|
||||
},
|
||||
"updates": {
|
||||
"available": "Update Available",
|
||||
"newVersion": "Version {{version}} is available",
|
||||
"currentVersion": "Current: {{version}}",
|
||||
"newFeatures": "New features include:",
|
||||
"channel": "Channel: {{channel}}",
|
||||
"channelStable": "Stable",
|
||||
"channelBeta": "Beta",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { UpdateNotification } from '../../components/admin/UpdateNotification';
|
||||
import { WhatsNewBanner } from '../../components/admin/WhatsNewBanner';
|
||||
import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
@@ -184,6 +185,8 @@ export const AdminDashboard: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* After-update "What's New" highlights (above the update banner) */}
|
||||
<WhatsNewBanner />
|
||||
{/* Update Notification */}
|
||||
<UpdateNotification />
|
||||
|
||||
|
||||
@@ -341,6 +341,22 @@ export interface AnalyticsData {
|
||||
devicesSource?: 'umami' | 'access_logs';
|
||||
}
|
||||
|
||||
export interface WhatsNewVersion {
|
||||
version: string;
|
||||
name: string;
|
||||
htmlUrl: string;
|
||||
publishedAt: string;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface WhatsNewResponse {
|
||||
enabled: boolean;
|
||||
hasNews: boolean;
|
||||
fromVersion?: string;
|
||||
toVersion?: string;
|
||||
versions?: WhatsNewVersion[];
|
||||
}
|
||||
|
||||
export const adminService = {
|
||||
// Dashboard statistics
|
||||
async getDashboardStats(): Promise<DashboardStats> {
|
||||
@@ -370,6 +386,16 @@ export const adminService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// After-update "What's New" highlights (per-instance acknowledgement).
|
||||
async getWhatsNew(): Promise<WhatsNewResponse> {
|
||||
const response = await api.get<WhatsNewResponse>('/admin/system/updates/whatsnew');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async markWhatsNewSeen(): Promise<void> {
|
||||
await api.post('/admin/system/updates/whatsnew/seen');
|
||||
},
|
||||
|
||||
// Backup-integrity verifier (read-only diagnostic). `scope` filters
|
||||
// which document classes to walk; omit for a full scan.
|
||||
async getBackupIntegrity(
|
||||
|
||||
Reference in New Issue
Block a user