diff --git a/ordinis-admin-ui/src/components/version/UpdateBanner.tsx b/ordinis-admin-ui/src/components/version/UpdateBanner.tsx index e45ee0a..51771ce 100644 --- a/ordinis-admin-ui/src/components/version/UpdateBanner.tsx +++ b/ordinis-admin-ui/src/components/version/UpdateBanner.tsx @@ -3,8 +3,7 @@ import { Button } from '@/ui' import { ArrowClockwise, BookOpen, X } from '@phosphor-icons/react' import { useState } from 'react' import { useAppVersion } from './useAppVersion' - -const DOCS_URL = '/docs/' // ordinis-docs deployment, mirrors prod /docs path +import { WhatsNewModal } from './WhatsNewModal' /** * Banner — показывается top-of-page когда client bundle отстаёт от server. @@ -16,7 +15,10 @@ const DOCS_URL = '/docs/' // ordinis-docs deployment, mirrors prod /docs path * * *

Dismiss button (×) — temporarily скрывает banner до следующего @@ -27,6 +29,7 @@ export function UpdateBanner() { const { t } = useTranslation() const { updateAvailable, serverTag } = useAppVersion() const [dismissed, setDismissed] = useState(false) + const [whatsNewOpen, setWhatsNewOpen] = useState(false) if (!updateAvailable || dismissed) return null @@ -39,40 +42,43 @@ export function UpdateBanner() { : t('updateBanner.messageGeneric') return ( -

-
- - {message} -
- - - + <> +
+
+ + {message} +
+ + + +
-
+ 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'ами: + *

    + *
  1. {@link WhatsNewButton} в TopBar — 🎁 icon с unread counter
  2. + *
  3. {@link UpdateBanner} «Что нового» button — когда update detected
  4. + *
+ * + *

При 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)} + + )} + +
  • + ))} +
+
+ ) +}