feat(whatsnew): экран «Что нового» — автопоказ после апдейта + кнопка в Settings
- src/shared/release-notes.ts — статический реестр заметок per-version (RU + EN), с тегами new/fix/security/perf для tint'а иконок. - Settings.lastSeenVersion — версия, для которой пользователь видел модалку. Валидатор регэксом /^\d+\.\d+\.\d+(-[\w.]+)?$/. - IPC.getAppVersion → app.getVersion() для renderer'а. - WhatsNewModal — список пунктов с цветовыми иконками. Footer-кнопка «Понятно» / «Got it». - App.tsx: после hydrate смотрит lastSeenVersion → current. Если расходятся и есть пропущенные заметки → автопоказ. На первой записи (lastSeenVersion === undefined) — тихо записываем, без модалки, чтобы не бить нового пользователя CHANGELOG'ом. - Settings → раздел «О приложении» → кнопка «Открыть» показывает модалку с заметками всех релизов.
This commit is contained in:
@@ -4,6 +4,8 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { Titlebar } from './components/Titlebar'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { WhatsNewModal } from './components/WhatsNewModal'
|
||||
import { unseenVersions } from '@shared/release-notes'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Exercises from './pages/Exercises'
|
||||
import GamesPage from './pages/Games'
|
||||
@@ -17,7 +19,12 @@ let backendSubscribed = false
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const hydrated = useAppStore((s) => s.hydrated)
|
||||
const settings = useAppStore((s) => s.state?.settings)
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||
const [whatsNew, setWhatsNew] = useState<{
|
||||
open: boolean
|
||||
versions: string[]
|
||||
}>({ open: false, versions: [] })
|
||||
|
||||
useEffect(() => {
|
||||
if (backendSubscribed) return undefined
|
||||
@@ -29,6 +36,43 @@ export default function App(): JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// После хидрации сверяем текущую версию приложения с lastSeenVersion.
|
||||
// Если первая хидрация и lastSeenVersion ещё не записан — это либо
|
||||
// первый запуск, либо обновление со старой версии (где поля не было) —
|
||||
// в любом случае пишем текущую версию и НЕ показываем модалку (мы не
|
||||
// хотим бить нового пользователя CHANGELOG'ом).
|
||||
// Если lastSeenVersion есть и не совпадает с current → показываем.
|
||||
useEffect(() => {
|
||||
if (!hydrated || !settings) return
|
||||
void window.api.getAppVersion().then((current) => {
|
||||
const last = settings.lastSeenVersion
|
||||
if (!last) {
|
||||
// Первая запись — сохраняем тихо.
|
||||
window.api.updateSettings({ lastSeenVersion: current })
|
||||
return
|
||||
}
|
||||
if (last !== current) {
|
||||
const versions = unseenVersions(current, last)
|
||||
if (versions.length > 0) {
|
||||
setWhatsNew({ open: true, versions })
|
||||
} else {
|
||||
// Версии есть, заметок нет — просто обновляем.
|
||||
window.api.updateSettings({ lastSeenVersion: current })
|
||||
}
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hydrated])
|
||||
|
||||
function closeWhatsNew(): void {
|
||||
setWhatsNew({ open: false, versions: [] })
|
||||
// Записываем «видел» только после закрытия — если пользователь убил
|
||||
// окно процессом до клика, при следующем запуске покажется снова.
|
||||
void window.api.getAppVersion().then((current) => {
|
||||
window.api.updateSettings({ lastSeenVersion: current })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<HashRouter>
|
||||
@@ -50,6 +94,11 @@ export default function App(): JSX.Element {
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<WhatsNewModal
|
||||
open={whatsNew.open}
|
||||
versions={whatsNew.versions}
|
||||
onClose={closeWhatsNew}
|
||||
/>
|
||||
</div>
|
||||
</HashRouter>
|
||||
</ErrorBoundary>
|
||||
|
||||
108
src/renderer/src/components/WhatsNewModal.tsx
Normal file
108
src/renderer/src/components/WhatsNewModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Sparkles, Wrench, Shield, Gauge } from 'lucide-react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { useT } from '../i18n'
|
||||
import { RELEASE_NOTES } from '@shared/release-notes'
|
||||
import type { ReleaseNoteItem } from '@shared/release-notes'
|
||||
import type { Language } from '@shared/types'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
versions: string[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const TAG_META = {
|
||||
new: { icon: Sparkles, cls: 'bg-accent text-white' },
|
||||
fix: { icon: Wrench, cls: 'bg-info text-white' },
|
||||
security: { icon: Shield, cls: 'bg-warning text-white' },
|
||||
perf: { icon: Gauge, cls: 'bg-success text-white' }
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Показывает заметки релизов для одной или нескольких версий. Используется
|
||||
* (a) автоматически после апдейта (когда `lastSeenVersion` != `currentVersion`)
|
||||
* и (b) вручную из Settings.
|
||||
*/
|
||||
export function WhatsNewModal({
|
||||
open,
|
||||
versions,
|
||||
onClose
|
||||
}: Props): JSX.Element {
|
||||
const { t, lang } = useT()
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('whatsnew.title')}
|
||||
size="md"
|
||||
footer={<Button onClick={onClose}>{t('whatsnew.btn.close')}</Button>}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{versions.length === 0 && (
|
||||
<div className="text-[14px] text-text/65 font-medium py-2">
|
||||
{t('whatsnew.empty')}
|
||||
</div>
|
||||
)}
|
||||
{versions.map((v) => (
|
||||
<VersionSection key={v} version={v} lang={lang} />
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function VersionSection({
|
||||
version,
|
||||
lang
|
||||
}: {
|
||||
version: string
|
||||
lang: Language
|
||||
}): JSX.Element {
|
||||
const items = useMemo<ReleaseNoteItem[]>(() => {
|
||||
const notes = RELEASE_NOTES[version]
|
||||
if (!notes) return []
|
||||
return notes[lang] ?? notes.ru
|
||||
}, [version, lang])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[12px] uppercase tracking-wider text-text/55 font-bold mb-3 font-mono-num">
|
||||
v{version}
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{items.map((it, i) => (
|
||||
<NoteRow key={i} item={it} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteRow({ item }: { item: ReleaseNoteItem }): JSX.Element {
|
||||
const meta = TAG_META[item.tag ?? 'new']
|
||||
const IconCmp = meta.icon
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={[
|
||||
'w-8 h-8 rounded-lg grid place-items-center shrink-0 mt-0.5',
|
||||
meta.cls
|
||||
].join(' ')}
|
||||
>
|
||||
<IconCmp size={15} strokeWidth={2.4} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-semibold leading-snug">
|
||||
{item.title}
|
||||
</div>
|
||||
{item.detail && (
|
||||
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||
{item.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -158,6 +158,13 @@ export const ru: Dict = {
|
||||
'Все текущие упражнения, история и настройки будут заменены содержимым файла. Продолжить?',
|
||||
'settings.data.import.ok': 'Восстановлено',
|
||||
'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?',
|
||||
'settings.section.about': 'О приложении',
|
||||
'settings.whatsnew.label': 'Что нового',
|
||||
'settings.whatsnew.hint': 'Посмотреть заметки последних релизов.',
|
||||
'settings.whatsnew.btn': 'Открыть',
|
||||
'whatsnew.title': 'Что нового',
|
||||
'whatsnew.btn.close': 'Понятно',
|
||||
'whatsnew.empty': 'Для этой версии заметок пока нет.',
|
||||
'settings.notification_mode.label': 'Режим уведомления',
|
||||
'settings.notification_mode.hint': 'Как должно выглядеть напоминание',
|
||||
'settings.notification_mode.modal': 'Окно поверх всех',
|
||||
@@ -466,6 +473,13 @@ export const en: Dict = {
|
||||
'All current exercises, history and settings will be replaced with the file contents. Continue?',
|
||||
'settings.data.import.ok': 'Restored',
|
||||
'settings.data.import.err': "Couldn't read the file — not our backup?",
|
||||
'settings.section.about': 'About',
|
||||
'settings.whatsnew.label': "What's new",
|
||||
'settings.whatsnew.hint': 'See the latest release notes.',
|
||||
'settings.whatsnew.btn': 'Open',
|
||||
'whatsnew.title': "What's new",
|
||||
'whatsnew.btn.close': 'Got it',
|
||||
'whatsnew.empty': 'No notes available for this version yet.',
|
||||
'settings.notification_mode.label': 'Notification mode',
|
||||
'settings.notification_mode.hint': 'How a reminder appears',
|
||||
'settings.notification_mode.modal': 'Window on top',
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useAppStore } from '../store/appStore'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
||||
import { UpdaterCard } from '../components/UpdaterCard'
|
||||
import { WhatsNewModal } from '../components/WhatsNewModal'
|
||||
import { RELEASE_NOTES } from '@shared/release-notes'
|
||||
import { useT } from '../i18n'
|
||||
import type {
|
||||
Language,
|
||||
@@ -175,11 +177,53 @@ export default function SettingsPage(): JSX.Element {
|
||||
<SectionHeader title={t('settings.section.data')} />
|
||||
<DataCard />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<SectionHeader title={t('settings.section.about')} />
|
||||
<AboutCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutCard(): JSX.Element {
|
||||
const { t } = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
// Все версии для которых у нас есть заметки, отсортированы desc.
|
||||
const allVersions = Object.keys(RELEASE_NOTES).sort((a, b) => {
|
||||
const pa = a.split('.').map(Number)
|
||||
const pb = b.split('.').map(Number)
|
||||
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pb[i] - pa[i]
|
||||
return 0
|
||||
})
|
||||
return (
|
||||
<Card>
|
||||
<Row last>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[15px] font-semibold leading-tight">
|
||||
{t('settings.whatsnew.label')}
|
||||
</div>
|
||||
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||
{t('settings.whatsnew.hint')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors"
|
||||
>
|
||||
{t('settings.whatsnew.btn')}
|
||||
</button>
|
||||
</Row>
|
||||
<WhatsNewModal
|
||||
open={open}
|
||||
versions={allVersions}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function DataCard(): JSX.Element {
|
||||
const { t } = useT()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
Reference in New Issue
Block a user