feat(topbar): «Что нового» button + 2026-05-12 state.md update
This commit is contained in:
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
|
||||
import { VersionBadge } from '@/components/version/VersionBadge'
|
||||
import { WhatsNewButton } from '@/components/version/WhatsNewButton'
|
||||
import { Bell, Menu } from 'lucide-react'
|
||||
|
||||
/**
|
||||
@@ -92,6 +93,7 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||
<span className="hidden md:inline-flex">
|
||||
<VersionBadge />
|
||||
</span>
|
||||
<WhatsNewButton />
|
||||
<NotificationsBell />
|
||||
</div>
|
||||
</header>
|
||||
@@ -103,6 +105,9 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||
* (webhook errors, draft reviews, system notices). Click → drawer/popup.
|
||||
* Сейчас static bell без counter — wire'ится когда backend notifications
|
||||
* stream появится.
|
||||
*
|
||||
* <p>«Что нового» (WhatsNewButton) — adjacent button показывает release
|
||||
* notes, отделён от operational notifications (разные mental models).
|
||||
*/
|
||||
function NotificationsBell() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GiftIcon, SparkleIcon } from '@phosphor-icons/react'
|
||||
import { Modal, Badge } from '@/ui'
|
||||
import {
|
||||
useChangelog,
|
||||
readLastSeenVersion,
|
||||
writeLastSeenVersion,
|
||||
isVersionNewer,
|
||||
type ChangelogEntry,
|
||||
type ChangelogSection,
|
||||
} from './useChangelog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* «Что нового» — button в TopBar + modal с release notes.
|
||||
*
|
||||
* <p>Behavior:
|
||||
* <ul>
|
||||
* <li>Button показывает sparkle icon + red dot если есть unread versions
|
||||
* (entries newer than {@code localStorage.ord-whatsnew-last-seen}).</li>
|
||||
* <li>Click → modal с list of versions, newest first, max 10 recent.</li>
|
||||
* <li>Modal open → mark latest version как seen (persisted в localStorage).</li>
|
||||
* <li>Дальнейшие visits: dot не показывается пока новый release tag не shipped.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Source: CHANGELOG.md bundled через Vite {@code ?raw} import → parsed
|
||||
* клиент-side. No backend changes.
|
||||
*
|
||||
* <p>i18n: button + modal title. Entries themselves остаются как-они есть из
|
||||
* CHANGELOG (mix ru/en commit messages — это intentional, авторы commit'ов
|
||||
* вписывают на родном языке).
|
||||
*/
|
||||
export function WhatsNewButton() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const entries = useChangelog()
|
||||
|
||||
// useState вместо useUnreadCount чтобы re-compute после modal close
|
||||
// (localStorage write не trigger'ит React re-render).
|
||||
const [lastSeen, setLastSeen] = useState<string | null>(() => readLastSeenVersion())
|
||||
const unreadCount = useMemo(
|
||||
() => entries.filter((e) => isVersionNewer(e.version, lastSeen)).length,
|
||||
[entries, lastSeen],
|
||||
)
|
||||
|
||||
// На modal close — persist latest version как seen.
|
||||
useEffect(() => {
|
||||
if (open && entries.length > 0) {
|
||||
const latest = entries[0].version
|
||||
writeLastSeenVersion(latest)
|
||||
setLastSeen(latest)
|
||||
}
|
||||
}, [open, entries])
|
||||
|
||||
// Show только 10 recent entries — older releases via «full changelog» link
|
||||
const recent = entries.slice(0, 10)
|
||||
|
||||
const label = t('whatsnew.button', { defaultValue: 'Что нового' })
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'relative size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2',
|
||||
'inline-flex items-center justify-center transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
<GiftIcon size={14} weight={unreadCount > 0 ? 'fill' : 'regular'} />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
aria-label={t('whatsnew.unread', {
|
||||
count: unreadCount,
|
||||
defaultValue: `${unreadCount} новых`,
|
||||
})}
|
||||
className="absolute -top-0.5 -right-0.5 inline-flex items-center justify-center min-w-[14px] h-3.5 px-1 rounded-full bg-mars text-on-mars text-[9px] font-semibold leading-none"
|
||||
>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<SparkleIcon size={18} weight="fill" className="text-accent" />
|
||||
{t('whatsnew.title', { defaultValue: 'Что нового в Ordinis' })}
|
||||
</span>
|
||||
}
|
||||
description={t('whatsnew.description', {
|
||||
defaultValue:
|
||||
'Последние релизы продукта. Обновления автоматически попадают на ваш сервер при следующем deploy.',
|
||||
})}
|
||||
maxWidth="max-w-2xl"
|
||||
>
|
||||
<div className="max-h-[60vh] overflow-y-auto -mx-1 px-1">
|
||||
{recent.length === 0 ? (
|
||||
<div className="text-mute text-body py-8 text-center">
|
||||
{t('whatsnew.empty', {
|
||||
defaultValue: 'Список релизов пока пуст.',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-5">
|
||||
{recent.map((entry) => (
|
||||
<li key={entry.version}>
|
||||
<EntryCard
|
||||
entry={entry}
|
||||
isUnread={isVersionNewer(entry.version, lastSeen)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-line-2 text-cap text-mute text-center">
|
||||
{t('whatsnew.footer', {
|
||||
defaultValue: 'Полный список изменений — см. CHANGELOG.md в репозитории.',
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EntryCard({ entry, isUnread }: { entry: ChangelogEntry; isUnread: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4 transition-colors',
|
||||
isUnread
|
||||
? 'border-accent/40 bg-accent-bg/40'
|
||||
: 'border-line bg-surface',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-3">
|
||||
<span className="text-title-sm text-ink font-semibold tabular-nums">
|
||||
v{entry.version}
|
||||
</span>
|
||||
{entry.date && (
|
||||
<span className="text-cap text-mute tabular-nums">{entry.date}</span>
|
||||
)}
|
||||
{isUnread && (
|
||||
<Badge variant="info" className="ml-auto">
|
||||
{t('whatsnew.newBadge', { defaultValue: 'новое' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{entry.sections.length === 0 ? (
|
||||
<div className="text-cell text-mute italic">
|
||||
{t('whatsnew.entryEmpty', {
|
||||
defaultValue: 'Описание не указано.',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entry.sections.map((section, i) => (
|
||||
<SectionBlock key={i} section={section} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionBlock({ section }: { section: ChangelogSection }) {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-cap text-mute font-semibold mb-1.5 inline-flex items-center gap-1.5">
|
||||
{section.emoji && <span aria-hidden>{section.emoji}</span>}
|
||||
<span>{section.title}</span>
|
||||
</h4>
|
||||
<ul className="space-y-1 ml-1">
|
||||
{section.items.map((item, i) => (
|
||||
<li key={i} className="text-body text-ink-2 flex items-start gap-1.5">
|
||||
<span className="text-mute mt-1 shrink-0" aria-hidden>•</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{item.scope && (
|
||||
<span className="text-mono text-cap text-accent mr-1.5">{item.scope}:</span>
|
||||
)}
|
||||
<span>{item.description}</span>
|
||||
{item.commit && item.commitUrl && (
|
||||
<a
|
||||
href={item.commitUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-mono text-cap text-mute hover:text-ink ml-1.5"
|
||||
title={`Commit ${item.commit}`}
|
||||
>
|
||||
{item.commit.substring(0, 7)}
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseChangelog, isVersionNewer } from './useChangelog'
|
||||
|
||||
describe('parseChangelog', () => {
|
||||
it('parses single version с features section', () => {
|
||||
const raw = `# Release Notes
|
||||
|
||||
## [2.14.0](https://example.com/compare/v2.13.0...v2.14.0) (2026-05-12)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* **webhook:** time-series histogram stats endpoint ([5479142](https://example.com/commit/5479142))
|
||||
`
|
||||
const entries = parseChangelog(raw)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0].version).toBe('2.14.0')
|
||||
expect(entries[0].date).toBe('2026-05-12')
|
||||
expect(entries[0].compareUrl).toContain('compare/v2.13.0...v2.14.0')
|
||||
expect(entries[0].sections).toHaveLength(1)
|
||||
expect(entries[0].sections[0].emoji).toBe('✨')
|
||||
expect(entries[0].sections[0].title).toBe('Features')
|
||||
expect(entries[0].sections[0].items).toHaveLength(1)
|
||||
expect(entries[0].sections[0].items[0].scope).toBe('webhook')
|
||||
expect(entries[0].sections[0].items[0].description).toBe('time-series histogram stats endpoint')
|
||||
expect(entries[0].sections[0].items[0].commit).toBe('5479142')
|
||||
expect(entries[0].sections[0].items[0].commitUrl).toContain('/commit/5479142')
|
||||
})
|
||||
|
||||
it('parses multiple versions newest-first', () => {
|
||||
const raw = `## [2.14.0](url1) (2026-05-12)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* **a:** first ([abc1234](url2))
|
||||
|
||||
## [2.13.0](url3) (2026-05-11)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
* **b:** second ([def5678](url4))
|
||||
`
|
||||
const entries = parseChangelog(raw)
|
||||
expect(entries.map((e) => e.version)).toEqual(['2.14.0', '2.13.0'])
|
||||
})
|
||||
|
||||
it('handles section без emoji', () => {
|
||||
const raw = `## [1.0.0](url) (2026-01-01)
|
||||
|
||||
### Features
|
||||
|
||||
* first feature
|
||||
`
|
||||
const entries = parseChangelog(raw)
|
||||
expect(entries[0].sections[0].emoji).toBeUndefined()
|
||||
expect(entries[0].sections[0].title).toBe('Features')
|
||||
})
|
||||
|
||||
it('handles item без scope', () => {
|
||||
const raw = `## [1.0.0](url) (2026-01-01)
|
||||
|
||||
### Features
|
||||
|
||||
* description without scope
|
||||
* another one ([abc1234](url2))
|
||||
`
|
||||
const entries = parseChangelog(raw)
|
||||
const items = entries[0].sections[0].items
|
||||
expect(items[0].scope).toBeUndefined()
|
||||
expect(items[0].description).toBe('description without scope')
|
||||
expect(items[1].scope).toBeUndefined()
|
||||
expect(items[1].description).toBe('another one')
|
||||
expect(items[1].commit).toBe('abc1234')
|
||||
})
|
||||
|
||||
it('handles empty changelog', () => {
|
||||
expect(parseChangelog('')).toEqual([])
|
||||
expect(parseChangelog('# Just a title\n\nNo versions yet.')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles version без date', () => {
|
||||
const raw = `## [1.0.0](url)
|
||||
|
||||
### Features
|
||||
|
||||
* foo
|
||||
`
|
||||
const entries = parseChangelog(raw)
|
||||
expect(entries[0].version).toBe('1.0.0')
|
||||
expect(entries[0].date).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles version с multiple sections', () => {
|
||||
const raw = `## [2.10.0](url) (2026-05-12)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* **timetravel:** new tab
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
* **ux:** friendly errors
|
||||
`
|
||||
const entries = parseChangelog(raw)
|
||||
expect(entries[0].sections).toHaveLength(2)
|
||||
expect(entries[0].sections[0].title).toBe('Features')
|
||||
expect(entries[0].sections[1].title).toBe('Fixes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isVersionNewer', () => {
|
||||
it('returns true когда seen is null', () => {
|
||||
expect(isVersionNewer('2.14.0', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('compares major.minor.patch correctly', () => {
|
||||
expect(isVersionNewer('2.14.0', '2.13.0')).toBe(true)
|
||||
expect(isVersionNewer('2.14.1', '2.14.0')).toBe(true)
|
||||
expect(isVersionNewer('3.0.0', '2.99.99')).toBe(true)
|
||||
expect(isVersionNewer('2.13.0', '2.14.0')).toBe(false)
|
||||
expect(isVersionNewer('2.14.0', '2.14.0')).toBe(false)
|
||||
})
|
||||
|
||||
it('strips v prefix', () => {
|
||||
expect(isVersionNewer('v2.14.0', '2.13.0')).toBe(true)
|
||||
expect(isVersionNewer('2.14.0', 'v2.13.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false для unparseable formats (treat as not newer)', () => {
|
||||
expect(isVersionNewer('dev', '2.14.0')).toBe(false)
|
||||
expect(isVersionNewer('2.14.0', 'dev')).toBe(false)
|
||||
expect(isVersionNewer('local', 'unknown')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useMemo } from 'react'
|
||||
// Public CHANGELOG (human-readable, без commit hashes / scope prefixes,
|
||||
// curated handler-friendly text). Lives в `docs/changelog.md` чтобы single
|
||||
// source of truth для public-facing changelog был один (docs build тоже
|
||||
// его публикует на /docs/changelog.html через Diplodoc).
|
||||
//
|
||||
// Internal CHANGELOG (semantic-release auto-generated, с commit hashes для
|
||||
// engineering team) — `ordinis-admin-ui/CHANGELOG.md`. Не bundled в UI.
|
||||
// eslint-disable-next-line import/no-unresolved -- Vite ?raw query resolved at build
|
||||
import changelogRaw from '../../../../docs/changelog.md?raw'
|
||||
|
||||
/**
|
||||
* Parsed CHANGELOG entry — one version block from semantic-release output.
|
||||
*
|
||||
* <p>Source format (semantic-release default):
|
||||
* <pre>
|
||||
* ## [2.14.0](https://git.nstart.cloud/.../compare/v2.13.0...v2.14.0) (2026-05-12)
|
||||
*
|
||||
* ### ✨ Features
|
||||
*
|
||||
* * **webhook:** time-series histogram stats endpoint ([5479142](...))
|
||||
*
|
||||
* ### 🐛 Fixes
|
||||
*
|
||||
* * **ux:** friendly error UI вместо raw AxiosError ([3984d99](...))
|
||||
* </pre>
|
||||
*
|
||||
* <p>Parsed → structured for «What's New» modal rendering.
|
||||
*/
|
||||
export type ChangelogEntry = {
|
||||
/** Version string без 'v' префикса (e.g. "2.14.0"). */
|
||||
version: string
|
||||
/** Compare link к git diff между previous и этой версии. */
|
||||
compareUrl?: string
|
||||
/** Release date (ISO yyyy-mm-dd) если parsed из header'а. */
|
||||
date?: string
|
||||
/** Категория → bullets. Категории: "Features", "Fixes", "Performance", etc. */
|
||||
sections: ChangelogSection[]
|
||||
}
|
||||
|
||||
export type ChangelogSection = {
|
||||
/** Display title без emoji (e.g. "Features"). */
|
||||
title: string
|
||||
/** Emoji preserved if present (e.g. "✨"). */
|
||||
emoji?: string
|
||||
items: ChangelogItem[]
|
||||
}
|
||||
|
||||
export type ChangelogItem = {
|
||||
/** Scope если был в commit'е (e.g. "webhook", "ui"). */
|
||||
scope?: string
|
||||
/** Description без scope + без commit hash link. */
|
||||
description: string
|
||||
/** Git commit hash (8 chars). */
|
||||
commit?: string
|
||||
/** Full commit URL. */
|
||||
commitUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CHANGELOG.md text → structured ChangelogEntry[].
|
||||
*
|
||||
* <p>Format assumptions (semantic-release default):
|
||||
* <ul>
|
||||
* <li>Version header: {@code ## [2.14.0](url) (yyyy-mm-dd)}</li>
|
||||
* <li>Section header: {@code ### ✨ Features} (emoji optional)</li>
|
||||
* <li>Item: {@code * **scope:** description ([hash](url))}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Robust against missing parts: пустые scope, missing commit URL,
|
||||
* missing date. Returns entries в порядке появления (newest first для
|
||||
* semantic-release output).
|
||||
*/
|
||||
export function parseChangelog(raw: string): ChangelogEntry[] {
|
||||
const entries: ChangelogEntry[] = []
|
||||
// Split на version headers — каждый ## [x.y.z] block.
|
||||
const versionRegex = /^##\s+\[([^\]]+)\](?:\(([^)]+)\))?\s*(?:\(([\d-]+)\))?/gm
|
||||
const matches: { start: number; end: number; version: string; url?: string; date?: string }[] = []
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = versionRegex.exec(raw)) !== null) {
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index, // tentative, finalized below
|
||||
version: m[1],
|
||||
url: m[2],
|
||||
date: m[3],
|
||||
})
|
||||
}
|
||||
// Finalize end indices — каждый block заканчивается где начинается следующий
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
matches[i].end = i + 1 < matches.length ? matches[i + 1].start : raw.length
|
||||
}
|
||||
|
||||
for (const block of matches) {
|
||||
const body = raw.substring(block.start, block.end)
|
||||
const sections = parseSections(body)
|
||||
entries.push({
|
||||
version: block.version,
|
||||
compareUrl: block.url,
|
||||
date: block.date,
|
||||
sections,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function parseSections(body: string): ChangelogSection[] {
|
||||
const sectionRegex = /^###\s+(?:(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s+)?(.+)$/gmu
|
||||
const sections: ChangelogSection[] = []
|
||||
const matches: { start: number; end: number; emoji?: string; title: string }[] = []
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = sectionRegex.exec(body)) !== null) {
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index,
|
||||
emoji: m[1],
|
||||
title: m[2].trim(),
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
matches[i].end = i + 1 < matches.length ? matches[i + 1].start : body.length
|
||||
}
|
||||
for (const s of matches) {
|
||||
const sectionBody = body.substring(s.start, s.end)
|
||||
const items = parseItems(sectionBody)
|
||||
sections.push({ title: s.title, emoji: s.emoji, items })
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
const ITEM_REGEX = /^\*\s+(?:\*\*([^:]+?):\*\*\s+)?(.+?)(?:\s+\(\[([a-f0-9]{7,})\]\(([^)]+)\)\))?\s*$/gm
|
||||
|
||||
function parseItems(sectionBody: string): ChangelogItem[] {
|
||||
const items: ChangelogItem[] = []
|
||||
let m: RegExpExecArray | null
|
||||
const regex = new RegExp(ITEM_REGEX.source, ITEM_REGEX.flags)
|
||||
while ((m = regex.exec(sectionBody)) !== null) {
|
||||
items.push({
|
||||
scope: m[1]?.trim(),
|
||||
description: m[2].trim(),
|
||||
commit: m[3],
|
||||
commitUrl: m[4],
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: returns parsed CHANGELOG entries. Memoized — parse runs once per
|
||||
* page load (CHANGELOG bundled into build, не fetched runtime).
|
||||
*
|
||||
* <p>Entries sorted newest-first (semantic-release output order).
|
||||
*/
|
||||
export function useChangelog(): ChangelogEntry[] {
|
||||
return useMemo(() => parseChangelog(changelogRaw), [])
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalStorage key для tracking last-seen version. Per-user (browser-scoped).
|
||||
* Если key empty / missing — пользователь никогда не открывал What's New →
|
||||
* unread = ALL entries (limited к recent N в UI).
|
||||
*/
|
||||
const LS_KEY = 'ord-whatsnew-last-seen'
|
||||
|
||||
/**
|
||||
* Returns version string from localStorage, или null если не set.
|
||||
*/
|
||||
export function readLastSeenVersion(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LS_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist last-seen version. Called когда user открывает What's New modal
|
||||
* AND видит latest entry. Throttle через just writing once per session
|
||||
* (modal close persists current latest version → next session checks delta).
|
||||
*/
|
||||
export function writeLastSeenVersion(version: string): void {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, version)
|
||||
} catch {
|
||||
// localStorage может быть disabled (Safari private mode) — fail silently
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare versions для unread detection. Semantic versioning major.minor.patch.
|
||||
* Returns true если {@code candidate} newer than {@code seen}.
|
||||
*
|
||||
* <p>Robust к unknown formats: «dev», «local», missing parts. Returns false
|
||||
* (treat as not-newer) для unparseable.
|
||||
*/
|
||||
export function isVersionNewer(candidate: string, seen: string | null): boolean {
|
||||
if (!seen) return true // нет baseline → все entries unread
|
||||
const parse = (v: string): number[] | null => {
|
||||
const m = v.match(/^v?(\d+)\.(\d+)\.(\d+)/)
|
||||
if (!m) return null
|
||||
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
|
||||
}
|
||||
const a = parse(candidate)
|
||||
const b = parse(seen)
|
||||
if (!a || !b) return false
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] > b[i]) return true
|
||||
if (a[i] < b[i]) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute count of entries newer than last seen — для notification dot
|
||||
* на «What's New» button.
|
||||
*/
|
||||
export function useUnreadCount(): number {
|
||||
const entries = useChangelog()
|
||||
return useMemo(() => {
|
||||
const seen = readLastSeenVersion()
|
||||
return entries.filter((e) => isVersionNewer(e.version, seen)).length
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- seen из localStorage, не trigger'ит re-render автоматически
|
||||
}, [entries])
|
||||
}
|
||||
@@ -24,6 +24,18 @@ i18n
|
||||
'nav.section.admin': 'Администрирование',
|
||||
'topbar.search.label': 'Поиск',
|
||||
'topbar.search.placeholder': 'Поиск по справочникам, ⌘K',
|
||||
'topbar.notifications': 'Уведомления',
|
||||
'whatsnew.button': 'Что нового',
|
||||
'whatsnew.title': 'Что нового в Ordinis',
|
||||
'whatsnew.description': 'Последние релизы продукта. Обновления автоматически попадают на ваш сервер при следующем deploy.',
|
||||
'whatsnew.unread_one': '{{count}} новое',
|
||||
'whatsnew.unread_few': '{{count}} новых',
|
||||
'whatsnew.unread_many': '{{count}} новых',
|
||||
'whatsnew.unread_other': '{{count}} новых',
|
||||
'whatsnew.newBadge': 'новое',
|
||||
'whatsnew.empty': 'Список релизов пока пуст.',
|
||||
'whatsnew.entryEmpty': 'Описание не указано.',
|
||||
'whatsnew.footer': 'Полный список изменений — см. CHANGELOG.md в репозитории.',
|
||||
'graph.title': 'Граф связей справочников',
|
||||
'graph.description': 'Сетевая диаграмма всех справочников и их FK-связей. Кликните по узлу — откроется детальный вид.',
|
||||
'graph.comingSoon.title': 'Скоро',
|
||||
@@ -734,6 +746,16 @@ i18n
|
||||
'nav.section.admin': 'Admin',
|
||||
'topbar.search.label': 'Search',
|
||||
'topbar.search.placeholder': 'Search dictionaries, ⌘K',
|
||||
'topbar.notifications': 'Notifications',
|
||||
'whatsnew.button': "What's new",
|
||||
'whatsnew.title': "What's new in Ordinis",
|
||||
'whatsnew.description': 'Latest releases. Updates land on your server with the next deploy.',
|
||||
'whatsnew.unread_one': '{{count}} new',
|
||||
'whatsnew.unread_other': '{{count}} new',
|
||||
'whatsnew.newBadge': 'new',
|
||||
'whatsnew.empty': 'No releases yet.',
|
||||
'whatsnew.entryEmpty': 'No description.',
|
||||
'whatsnew.footer': 'Full changelog — see CHANGELOG.md in repository.',
|
||||
'graph.title': 'Dictionary relations graph',
|
||||
'graph.description': 'Network diagram of all dictionaries and their FK links. Click a node for detail.',
|
||||
'graph.comingSoon.title': 'Coming soon',
|
||||
|
||||
@@ -74,6 +74,12 @@ export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
fs: {
|
||||
// Allow importing files outside admin-ui (e.g. ../docs/changelog.md
|
||||
// для WhatsNewButton — single source of truth для public changelog
|
||||
// живёт в docs/ чтобы mkdocs тоже его publish'ил).
|
||||
allow: ['..'],
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_PROXY ?? 'http://localhost:8080',
|
||||
|
||||
Reference in New Issue
Block a user