Merge pull request #694 from Luca-Timo/feat/whatsnew-notifications
feat(updates): "What's New" highlights after update + pre-update teaser
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
const { parseWhatsNew } = require('../../src/utils/whatsNew');
|
||||||
|
|
||||||
|
describe('parseWhatsNew', () => {
|
||||||
|
it('prefers the curated <!-- whatsnew --> block', () => {
|
||||||
|
const body = [
|
||||||
|
'<!-- whatsnew -->',
|
||||||
|
'- Invoice drafts in list',
|
||||||
|
'- Bank transfer payments',
|
||||||
|
'<!-- /whatsnew -->',
|
||||||
|
'',
|
||||||
|
'### Features',
|
||||||
|
'* **invoices:** something long that should be ignored ([#1](http://x))',
|
||||||
|
].join('\n');
|
||||||
|
expect(parseWhatsNew(body)).toEqual(['Invoice drafts in list', 'Bank transfer payments']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the Features section, stripping scope + commit links', () => {
|
||||||
|
const body = [
|
||||||
|
'## [3.73.0-beta.0](http://x) (2026-06-29)',
|
||||||
|
'',
|
||||||
|
'### Features',
|
||||||
|
'',
|
||||||
|
'* **dashboard:** revenue tile toggles 365 days ([d1c9e02](http://c))',
|
||||||
|
'* **invoices:** surface monthly drafts in the Bills list ([e457656](http://c))',
|
||||||
|
'',
|
||||||
|
'### Bug Fixes',
|
||||||
|
'',
|
||||||
|
'* **invoices:** add bank transfer ([e96ef4c](http://c))',
|
||||||
|
].join('\n');
|
||||||
|
expect(parseWhatsNew(body)).toEqual([
|
||||||
|
'revenue tile toggles 365 days',
|
||||||
|
'surface monthly drafts in the Bills list',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes Bug Fixes from the fallback', () => {
|
||||||
|
const body = '### Features\n* **a:** feature one\n### Bug Fixes\n* **b:** fix one';
|
||||||
|
expect(parseWhatsNew(body)).toEqual(['feature one']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at 8 bullets and de-dups', () => {
|
||||||
|
const lines = Array.from({ length: 12 }, (_, i) => `- bullet ${i % 9}`);
|
||||||
|
const body = `<!-- whatsnew -->\n${lines.join('\n')}\n<!-- /whatsnew -->`;
|
||||||
|
const out = parseWhatsNew(body);
|
||||||
|
expect(out.length).toBe(8);
|
||||||
|
expect(new Set(out).size).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for empty / non-string input', () => {
|
||||||
|
expect(parseWhatsNew('')).toEqual([]);
|
||||||
|
expect(parseWhatsNew(null)).toEqual([]);
|
||||||
|
expect(parseWhatsNew(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,9 @@ const path = require('path');
|
|||||||
const os = require('os');
|
const os = require('os');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { checkForUpdates, getCurrentChannel, getReleasesSince } = require('../services/updateCheckService');
|
const { checkForUpdates, getCurrentChannel, getCurrentVersion, getReleasesSince, compareVersions } = require('../services/updateCheckService');
|
||||||
|
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
|
||||||
|
const { parseWhatsNew } = require('../utils/whatsNew');
|
||||||
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
|
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
|
||||||
const {
|
const {
|
||||||
checkAndNotifyUpdates,
|
checkAndNotifyUpdates,
|
||||||
@@ -61,9 +63,20 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
|
|||||||
const forceRefresh = req.query.refresh === 'true';
|
const forceRefresh = req.query.refresh === 'true';
|
||||||
const updateInfo = await checkForUpdates(forceRefresh);
|
const updateInfo = await checkForUpdates(forceRefresh);
|
||||||
|
|
||||||
|
// Pre-update teaser: the target version's top highlights, so the
|
||||||
|
// "Update Available" banner can show "New features include …".
|
||||||
|
let latestHighlights = [];
|
||||||
|
if (updateInfo.updateAvailable) {
|
||||||
|
try {
|
||||||
|
const newer = await getReleasesSince(updateInfo.current, updateInfo.channel);
|
||||||
|
if (newer[0]) latestHighlights = parseWhatsNew(newer[0].body);
|
||||||
|
} catch (_) { /* teaser is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...updateInfo
|
...updateInfo,
|
||||||
|
latestHighlights
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error checking for updates:', error);
|
logger.error('Error checking for updates:', error);
|
||||||
@@ -71,6 +84,69 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// What's New — after-update highlights. Returns the curated bullets for
|
||||||
|
// every release the instance moved THROUGH since it last acknowledged one
|
||||||
|
// (lastSeen < version <= running). Seen-tracking is per-INSTANCE: the first
|
||||||
|
// admin to dismiss clears it for everyone (a single app_settings row). A
|
||||||
|
// brand-new install initialises the marker silently so it never pops
|
||||||
|
// "what's new" with nothing to compare against. Best-effort: any failure
|
||||||
|
// (GitHub unreachable, etc.) returns hasNews:false, never errors.
|
||||||
|
router.get('/updates/whatsnew', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (process.env.UPDATE_CHECK_ENABLED === 'false') {
|
||||||
|
return res.json({ enabled: false, hasNews: false });
|
||||||
|
}
|
||||||
|
const running = await getCurrentVersion();
|
||||||
|
const channel = getCurrentChannel(running);
|
||||||
|
const lastSeen = await getAppSetting('whatsnew_last_seen_version', null);
|
||||||
|
|
||||||
|
if (!lastSeen) {
|
||||||
|
await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system');
|
||||||
|
return res.json({ enabled: true, hasNews: false, running });
|
||||||
|
}
|
||||||
|
if (compareVersions(running, lastSeen) <= 0) {
|
||||||
|
return res.json({ enabled: true, hasNews: false, running });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Releases in (lastSeen, running], newest-first, with their highlights.
|
||||||
|
const releases = (await getReleasesSince(lastSeen, channel))
|
||||||
|
.filter((r) => compareVersions(r.version, running) <= 0);
|
||||||
|
const versions = releases
|
||||||
|
.map((r) => ({
|
||||||
|
version: r.version,
|
||||||
|
name: r.name,
|
||||||
|
publishedAt: r.publishedAt,
|
||||||
|
htmlUrl: r.htmlUrl,
|
||||||
|
bullets: parseWhatsNew(r.body),
|
||||||
|
}))
|
||||||
|
.filter((v) => v.bullets.length > 0);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
enabled: true,
|
||||||
|
hasNews: versions.length > 0,
|
||||||
|
fromVersion: lastSeen,
|
||||||
|
toVersion: running,
|
||||||
|
versions,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error building what\'s-new:', error);
|
||||||
|
res.json({ enabled: true, hasNews: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Acknowledge the What's New — advance the per-instance marker to the
|
||||||
|
// running version so it stops showing for every admin.
|
||||||
|
router.post('/updates/whatsnew/seen', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const running = await getCurrentVersion();
|
||||||
|
await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system');
|
||||||
|
res.json({ ok: true, lastSeen: running });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error marking what\'s-new seen:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update marker' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Aggregated changelog — every release between current and latest in
|
// Aggregated changelog — every release between current and latest in
|
||||||
// the user's channel. Powers the update-available modal (#567) so the
|
// the user's channel. Powers the update-available modal (#567) so the
|
||||||
// admin can read release notes for ALL versions they're behind on, not
|
// admin can read release notes for ALL versions they're behind on, not
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract user-facing "What's New" bullets from a GitHub release body.
|
||||||
|
*
|
||||||
|
* Prefers the curated block the release CI injects via GitHub Models:
|
||||||
|
* <!-- whatsnew -->\n- bullet\n- bullet\n<!-- /whatsnew -->
|
||||||
|
* and falls back to the release-please "### Features" section for releases
|
||||||
|
* created before that CI step existed — so the feature works against
|
||||||
|
* today's releases too (just with longer, auto-cleaned lines).
|
||||||
|
*
|
||||||
|
* Returns at most MAX_BULLETS cleaned strings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_BULLETS = 8;
|
||||||
|
|
||||||
|
/** Strip list markers, conventional-commit scope, and trailing PR/sha links. */
|
||||||
|
function cleanBullet(line) {
|
||||||
|
return line
|
||||||
|
.replace(/^\s*[-*]\s+/, '') // "- " / "* " marker
|
||||||
|
.replace(/^\*\*([^:*]+):\*\*\s*/, '') // "**scope:** " prefix
|
||||||
|
.replace(/\s*\(\[[^\]]*\]\([^)]*\)\)/g, '') // " ([#41](url))" / " ([sha](url))"
|
||||||
|
.replace(/\s*\(#\d+\)/g, '') // bare " (#41)"
|
||||||
|
.replace(/`/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the lines under a "## Features" / "### Features" heading. */
|
||||||
|
function extractFeaturesSection(body) {
|
||||||
|
const out = [];
|
||||||
|
let inFeatures = false;
|
||||||
|
for (const raw of body.split('\n')) {
|
||||||
|
const line = raw.trimEnd();
|
||||||
|
if (/^#{2,4}\s+Features\b/i.test(line)) { inFeatures = true; continue; }
|
||||||
|
if (inFeatures && /^#{1,4}\s+\S/.test(line)) break; // next heading ends the section
|
||||||
|
if (inFeatures) out.push(line);
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} body release notes markdown
|
||||||
|
* @returns {string[]} up to 8 user-facing bullet strings
|
||||||
|
*/
|
||||||
|
function parseWhatsNew(body) {
|
||||||
|
if (!body || typeof body !== 'string') return [];
|
||||||
|
const block = body.match(/<!--\s*whatsnew\s*-->([\s\S]*?)<!--\s*\/whatsnew\s*-->/i);
|
||||||
|
const src = block ? block[1] : extractFeaturesSection(body);
|
||||||
|
const bullets = src
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => /^[-*]\s+/.test(l))
|
||||||
|
.map(cleanBullet)
|
||||||
|
.filter((l) => l.length > 0);
|
||||||
|
// De-dup while preserving order, then cap.
|
||||||
|
const seen = new Set();
|
||||||
|
const unique = [];
|
||||||
|
for (const b of bullets) {
|
||||||
|
const key = b.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
unique.push(b);
|
||||||
|
}
|
||||||
|
return unique.slice(0, MAX_BULLETS);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseWhatsNew };
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# What's New highlights — GitHub Models release step
|
||||||
|
#
|
||||||
|
# DRAFT — move this into `.github/workflows/` on the repo that publishes the
|
||||||
|
# canonical releases the app reads (PicPeak/picpeak). It is kept under docs/
|
||||||
|
# here so it does NOT auto-run on the fork.
|
||||||
|
#
|
||||||
|
# What it does: when a release is published (release-please), it condenses that
|
||||||
|
# release's "### Features" into <=8 short bullets via GitHub Models (free tier,
|
||||||
|
# `models: read`) and injects a `<!-- whatsnew -->` block at the top of the
|
||||||
|
# release notes. The picpeak app reads that block (utils/whatsNew.parseWhatsNew),
|
||||||
|
# and falls back to the raw Features list for releases without it — so this is
|
||||||
|
# purely a quality upgrade, never a hard dependency.
|
||||||
|
#
|
||||||
|
# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into
|
||||||
|
# real release notes; app parseWhatsNew() reads the block back). The one thing
|
||||||
|
# that can't be checked from a fork: GitHub Models must be enabled for the
|
||||||
|
# PicPeak ORG (owner setting). Failure is isolated anyway by `continue-on-error`
|
||||||
|
# + the deterministic fallback below, so it can never break a release.
|
||||||
|
|
||||||
|
name: What's New highlights
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # to edit the release body
|
||||||
|
models: read # GitHub Models (free tier)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
highlights:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Extract Features from the published release
|
||||||
|
id: feat
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ github.event.release.tag_name }}
|
||||||
|
run: |
|
||||||
|
BODY=$(gh release view "$TAG" --json body -q .body)
|
||||||
|
FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f')
|
||||||
|
{ echo "features<<EOF"; printf '%s\n' "$FEATURES"; echo EOF; } >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Summarize with GitHub Models
|
||||||
|
if: ${{ steps.feat.outputs.features != '' }}
|
||||||
|
id: ai
|
||||||
|
uses: actions/ai-inference@v1
|
||||||
|
with:
|
||||||
|
model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work
|
||||||
|
system-prompt: >
|
||||||
|
You write release highlights for the admins of a self-hosted
|
||||||
|
photo-gallery + CRM app. Given raw changelog "Features" lines, output
|
||||||
|
AT MOST 8 markdown bullets, each 3-4 words, user-facing, no scopes,
|
||||||
|
no jargon, no issue numbers. One bullet per distinct user-visible
|
||||||
|
feature. Output ONLY "- " bullets, nothing else.
|
||||||
|
prompt: ${{ steps.feat.outputs.features }}
|
||||||
|
|
||||||
|
- name: Inject the What's New block
|
||||||
|
if: ${{ steps.feat.outputs.features != '' }}
|
||||||
|
continue-on-error: true # never let highlights break a release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ github.event.release.tag_name }}
|
||||||
|
AI: ${{ steps.ai.outputs.response }}
|
||||||
|
FEATURES: ${{ steps.feat.outputs.features }}
|
||||||
|
run: |
|
||||||
|
BULLETS="$AI"
|
||||||
|
# Deterministic fallback if the model returned nothing.
|
||||||
|
if [ -z "$BULLETS" ]; then
|
||||||
|
BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \
|
||||||
|
| sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g')
|
||||||
|
fi
|
||||||
|
BODY=$(gh release view "$TAG" --json body -q .body)
|
||||||
|
# Idempotent: strip any prior block before re-injecting.
|
||||||
|
BODY=$(printf '%s' "$BODY" | perl -0pe 's/<!--\s*whatsnew\s*-->.*?<!--\s*\/whatsnew\s*-->\n*//is')
|
||||||
|
gh release edit "$TAG" --notes "$(printf '<!-- whatsnew -->\n%s\n<!-- /whatsnew -->\n\n%s' "$BULLETS" "$BODY")"
|
||||||
@@ -19,6 +19,8 @@ interface UpdateInfo {
|
|||||||
lastChecked: string;
|
lastChecked: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
/** Top highlights of the target version (pre-update teaser). */
|
||||||
|
latestHighlights?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUpdateInfo(): Promise<UpdateInfo> {
|
async function fetchUpdateInfo(): Promise<UpdateInfo> {
|
||||||
@@ -81,6 +83,16 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
|||||||
channel: channelLabel
|
channel: channelLabel
|
||||||
})}
|
})}
|
||||||
</p>
|
</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">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInstructions(true)}
|
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",
|
"warning": "Warnung",
|
||||||
"error": "Fehler"
|
"error": "Fehler"
|
||||||
},
|
},
|
||||||
|
"whatsnew": {
|
||||||
|
"title": "Neu in {{version}}",
|
||||||
|
"viewAll": "Was ist neu",
|
||||||
|
"modalTitle": "Was ist neu",
|
||||||
|
"fullChangelog": "Vollständiges Änderungsprotokoll",
|
||||||
|
"gotIt": "Verstanden"
|
||||||
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"available": "Update verfügbar",
|
"available": "Update verfügbar",
|
||||||
"newVersion": "Version {{version}} ist verfügbar",
|
"newVersion": "Version {{version}} ist verfügbar",
|
||||||
"currentVersion": "Aktuell: {{version}}",
|
"currentVersion": "Aktuell: {{version}}",
|
||||||
|
"newFeatures": "Neue Funktionen:",
|
||||||
"channel": "Kanal: {{channel}}",
|
"channel": "Kanal: {{channel}}",
|
||||||
"channelStable": "Stabil",
|
"channelStable": "Stabil",
|
||||||
"channelBeta": "Beta",
|
"channelBeta": "Beta",
|
||||||
|
|||||||
@@ -1880,10 +1880,18 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"checking": "Checking..."
|
"checking": "Checking..."
|
||||||
},
|
},
|
||||||
|
"whatsnew": {
|
||||||
|
"title": "What's new in {{version}}",
|
||||||
|
"viewAll": "What's new",
|
||||||
|
"modalTitle": "What's new",
|
||||||
|
"fullChangelog": "Full changelog",
|
||||||
|
"gotIt": "Got it"
|
||||||
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"available": "Update Available",
|
"available": "Update Available",
|
||||||
"newVersion": "Version {{version}} is available",
|
"newVersion": "Version {{version}} is available",
|
||||||
"currentVersion": "Current: {{version}}",
|
"currentVersion": "Current: {{version}}",
|
||||||
|
"newFeatures": "New features include:",
|
||||||
"channel": "Channel: {{channel}}",
|
"channel": "Channel: {{channel}}",
|
||||||
"channelStable": "Stable",
|
"channelStable": "Stable",
|
||||||
"channelBeta": "Beta",
|
"channelBeta": "Beta",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
|||||||
|
|
||||||
import { Button, Card, Loading } from '../../components/common';
|
import { Button, Card, Loading } from '../../components/common';
|
||||||
import { UpdateNotification } from '../../components/admin/UpdateNotification';
|
import { UpdateNotification } from '../../components/admin/UpdateNotification';
|
||||||
|
import { WhatsNewBanner } from '../../components/admin/WhatsNewBanner';
|
||||||
import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection';
|
import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
@@ -184,6 +185,8 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* After-update "What's New" highlights (above the update banner) */}
|
||||||
|
<WhatsNewBanner />
|
||||||
{/* Update Notification */}
|
{/* Update Notification */}
|
||||||
<UpdateNotification />
|
<UpdateNotification />
|
||||||
|
|
||||||
|
|||||||
@@ -341,6 +341,22 @@ export interface AnalyticsData {
|
|||||||
devicesSource?: 'umami' | 'access_logs';
|
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 = {
|
export const adminService = {
|
||||||
// Dashboard statistics
|
// Dashboard statistics
|
||||||
async getDashboardStats(): Promise<DashboardStats> {
|
async getDashboardStats(): Promise<DashboardStats> {
|
||||||
@@ -370,6 +386,16 @@ export const adminService = {
|
|||||||
return response.data;
|
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
|
// Backup-integrity verifier (read-only diagnostic). `scope` filters
|
||||||
// which document classes to walk; omit for a full scan.
|
// which document classes to walk; omit for a full scan.
|
||||||
async getBackupIntegrity(
|
async getBackupIntegrity(
|
||||||
|
|||||||
Reference in New Issue
Block a user