setWhatsNewOpen(false)} />
+ >
)
}
diff --git a/ordinis-admin-ui/src/components/version/WhatsNewButton.tsx b/ordinis-admin-ui/src/components/version/WhatsNewButton.tsx
index 2b28233..379c7f0 100644
--- a/ordinis-admin-ui/src/components/version/WhatsNewButton.tsx
+++ b/ordinis-admin-ui/src/components/version/WhatsNewButton.tsx
@@ -1,61 +1,39 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { GiftIcon, SparkleIcon } from '@phosphor-icons/react'
-import { Modal, Badge } from '@/ui'
+import { GiftIcon } from '@phosphor-icons/react'
import {
useChangelog,
readLastSeenVersion,
- writeLastSeenVersion,
isVersionNewer,
- type ChangelogEntry,
- type ChangelogSection,
} from './useChangelog'
+import { WhatsNewModal } from './WhatsNewModal'
import { cn } from '@/lib/utils'
/**
- * «Что нового» — button в TopBar + modal с release notes.
+ * «Что нового» button в TopBar.
*
- * Behavior:
- *
- * - Button показывает sparkle icon + red dot если есть unread versions
- * (entries newer than {@code localStorage.ord-whatsnew-last-seen}).
- * - Click → modal с list of versions, newest first, max 10 recent.
- * - Modal open → mark latest version как seen (persisted в localStorage).
- * - Дальнейшие visits: dot не показывается пока новый release tag не shipped.
- *
+ * 🎁 GiftIcon с red unread badge counter (entries newer than
+ * {@code localStorage.ord-whatsnew-last-seen}). Click → {@link WhatsNewModal}.
*
- *
Source: CHANGELOG.md bundled через Vite {@code ?raw} import → parsed
- * клиент-side. No backend changes.
- *
- *
i18n: button + modal title. Entries themselves остаются как-они есть из
- * CHANGELOG (mix ru/en commit messages — это intentional, авторы commit'ов
- * вписывают на родном языке).
+ *
Modal extracted в own component — также используется в
+ * {@link UpdateBanner} «Что нового» button (когда новая версия detected).
*/
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(() => readLastSeenVersion())
+ // useState lastSeen — re-compute unread после modal close. localStorage
+ // write (внутри WhatsNewModal на open) не trigger'ит React re-render
+ // отдельно, поэтому tracking через useEffect on open close в modal.
+ // В button мы читаем initially — на close button перестроится через
+ // обычный rerender chain.
+ const lastSeen = useMemo(() => readLastSeenVersion(), [open])
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 (
@@ -85,123 +63,7 @@ export function WhatsNewButton() {
)}
- setOpen(false)}
- title={
-
-
- {t('whatsnew.title', { defaultValue: 'Что нового в Ordinis' })}
-
- }
- description={t('whatsnew.description', {
- defaultValue:
- 'Последние релизы продукта. Обновления автоматически попадают на ваш сервер при следующем deploy.',
- })}
- maxWidth="max-w-2xl"
- >
-
- {recent.length === 0 ? (
-
- {t('whatsnew.empty', {
- defaultValue: 'Список релизов пока пуст.',
- })}
-
- ) : (
-
- {recent.map((entry) => (
- -
-
-
- ))}
-
- )}
-
-
- {t('whatsnew.footer', {
- defaultValue: 'Полный список изменений — см. CHANGELOG.md в репозитории.',
- })}
-
-
+ setOpen(false)} />
>
)
}
-
-function EntryCard({ entry, isUnread }: { entry: ChangelogEntry; isUnread: boolean }) {
- const { t } = useTranslation()
- return (
-
-
-
- v{entry.version}
-
- {entry.date && (
- {entry.date}
- )}
- {isUnread && (
-
- {t('whatsnew.newBadge', { defaultValue: 'новое' })}
-
- )}
-
- {entry.sections.length === 0 ? (
-
- {t('whatsnew.entryEmpty', {
- defaultValue: 'Описание не указано.',
- })}
-
- ) : (
-
- {entry.sections.map((section, i) => (
-
- ))}
-
- )}
-
- )
-}
-
-function SectionBlock({ section }: { section: ChangelogSection }) {
- return (
-
-
- {section.emoji && {section.emoji}}
- {section.title}
-
-
- {section.items.map((item, i) => (
- -
- •
-
- {item.scope && (
- {item.scope}:
- )}
- {item.description}
- {item.commit && item.commitUrl && (
-
- {item.commit.substring(0, 7)}
-
- )}
-
-
- ))}
-
-
- )
-}
diff --git a/ordinis-admin-ui/src/components/version/WhatsNewModal.tsx b/ordinis-admin-ui/src/components/version/WhatsNewModal.tsx
new file mode 100644
index 0000000..134e823
--- /dev/null
+++ b/ordinis-admin-ui/src/components/version/WhatsNewModal.tsx
@@ -0,0 +1,172 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { 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'
+
+/**
+ * «Что нового» — modal с release notes из public changelog.
+ *
+ * Open state управляется снаружи (controlled component). Используется
+ * двумя trigger'ами:
+ *
+ * - {@link WhatsNewButton} в TopBar — 🎁 icon с unread counter
+ * - {@link UpdateBanner} «Что нового» button — когда update detected
+ *
+ *
+ * При open: marks latest version как seen в localStorage. На next visit
+ * unread counter = 0 пока не shipped новая версия.
+ *
+ *
Source: `docs/changelog.md` через `useChangelog` hook (Vite `?raw` import).
+ */
+export type WhatsNewModalProps = {
+ open: boolean
+ onClose: () => void
+}
+
+export function WhatsNewModal({ open, onClose }: WhatsNewModalProps) {
+ const { t } = useTranslation()
+ const entries = useChangelog()
+ const [lastSeen, setLastSeen] = useState(() => readLastSeenVersion())
+
+ // На modal open — persist latest version как seen (red dot disappears
+ // для next session, пока новая версия не shipped).
+ useEffect(() => {
+ if (open && entries.length > 0) {
+ const latest = entries[0].version
+ writeLastSeenVersion(latest)
+ setLastSeen(latest)
+ }
+ }, [open, entries])
+
+ // Show только 10 recent entries — older releases в полном CHANGELOG.
+ const recent = entries.slice(0, 10)
+
+ return (
+
+
+ {t('whatsnew.title', { defaultValue: 'Что нового в Ordinis' })}
+
+ }
+ description={t('whatsnew.description', {
+ defaultValue:
+ 'Последние релизы продукта. Обновления автоматически попадают на ваш сервер при следующем deploy.',
+ })}
+ maxWidth="max-w-2xl"
+ >
+
+ {recent.length === 0 ? (
+
+ {t('whatsnew.empty', {
+ defaultValue: 'Список релизов пока пуст.',
+ })}
+
+ ) : (
+
+ {recent.map((entry) => (
+ -
+
+
+ ))}
+
+ )}
+
+
+ {t('whatsnew.footer', {
+ defaultValue: 'Полный список изменений — см. CHANGELOG.md в репозитории.',
+ })}
+
+
+ )
+}
+
+function EntryCard({ entry, isUnread }: { entry: ChangelogEntry; isUnread: boolean }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+ v{entry.version}
+
+ {entry.date && (
+ {entry.date}
+ )}
+ {isUnread && (
+
+ {t('whatsnew.newBadge', { defaultValue: 'новое' })}
+
+ )}
+
+ {entry.sections.length === 0 ? (
+
+ {t('whatsnew.entryEmpty', {
+ defaultValue: 'Описание не указано.',
+ })}
+
+ ) : (
+
+ {entry.sections.map((section, i) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function SectionBlock({ section }: { section: ChangelogSection }) {
+ return (
+
+
+ {section.emoji && {section.emoji}}
+ {section.title}
+
+
+ {section.items.map((item, i) => (
+ -
+ •
+
+ {item.scope && (
+ {item.scope}:
+ )}
+ {item.description}
+ {item.commit && item.commitUrl && (
+
+ {item.commit.substring(0, 7)}
+
+ )}
+
+
+ ))}
+
+
+ )
+}