feat(topbar): «Что нового» button + 2026-05-12 state.md update

This commit is contained in:
Александр Зимин
2026-05-12 17:05:24 +00:00
parent 58dd871808
commit a34c6d4c82
21 changed files with 1853 additions and 8 deletions
@@ -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>
)
}