diff --git a/client/src/App.vue b/client/src/App.vue index 38112bda3..93c1b9f15 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,16 +1,39 @@ + + diff --git a/client/src/features/whats-new/WhatsNewView.vue b/client/src/features/whats-new/WhatsNewView.vue new file mode 100644 index 000000000..eaffb7fbc --- /dev/null +++ b/client/src/features/whats-new/WhatsNewView.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/client/src/features/whats-new/components/HighlightIcon.vue b/client/src/features/whats-new/components/HighlightIcon.vue new file mode 100644 index 000000000..5a5b618ab --- /dev/null +++ b/client/src/features/whats-new/components/HighlightIcon.vue @@ -0,0 +1,20 @@ + + + diff --git a/client/src/features/whats-new/components/HighlightItem.vue b/client/src/features/whats-new/components/HighlightItem.vue new file mode 100644 index 000000000..9680e1ec3 --- /dev/null +++ b/client/src/features/whats-new/components/HighlightItem.vue @@ -0,0 +1,93 @@ + + + diff --git a/client/src/features/whats-new/components/ImageLightbox.vue b/client/src/features/whats-new/components/ImageLightbox.vue new file mode 100644 index 000000000..c1c2bb853 --- /dev/null +++ b/client/src/features/whats-new/components/ImageLightbox.vue @@ -0,0 +1,92 @@ + + + diff --git a/client/src/features/whats-new/composables/useWhatsNew.test.ts b/client/src/features/whats-new/composables/useWhatsNew.test.ts new file mode 100644 index 000000000..83d641e53 --- /dev/null +++ b/client/src/features/whats-new/composables/useWhatsNew.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import type { ReleaseNote } from '@bookorbit/types' + +import { resetWhatsNew, useWhatsNew } from './useWhatsNew' + +describe('resetWhatsNew', () => { + it('clears module-scope state so a user switch cannot inherit the previous user state', () => { + const { releases, popupEnabled, popupOpen, hasMore, error } = useWhatsNew() + + releases.value = [{ version: 'v1.2.0' } as ReleaseNote] + popupEnabled.value = false + popupOpen.value = true + hasMore.value = true + error.value = 'boom' + + resetWhatsNew() + + expect(releases.value).toEqual([]) + expect(popupEnabled.value).toBe(true) + expect(popupOpen.value).toBe(false) + expect(hasMore.value).toBe(false) + expect(error.value).toBeNull() + }) +}) diff --git a/client/src/features/whats-new/composables/useWhatsNew.ts b/client/src/features/whats-new/composables/useWhatsNew.ts new file mode 100644 index 000000000..eb34ac7ae --- /dev/null +++ b/client/src/features/whats-new/composables/useWhatsNew.ts @@ -0,0 +1,154 @@ +import { computed, ref } from 'vue' +import type { ReleaseNote, ReleaseNotesResponse, WhatsNewPreferences } from '@bookorbit/types' +import { api } from '@/lib/api' +import { useAppInfo } from '@/features/settings/composables/useAppInfo' +import { isNewer, isSemverTag, shouldShowPopup } from '../lib/whats-new.logic' + +const lastSeenVersion = ref(null) +const popupEnabled = ref(true) +const releases = ref([]) +const hasMore = ref(false) +const popupOpen = ref(false) +const loading = ref(false) +const error = ref(null) + +let prefsLoaded = false +let evaluated = false +let evaluating = false +let dismissedThisSession = false + +/** + * Clears the module-scope singleton so a user switch in the same SPA session can't inherit the + * previous user's seen-state. Call from auth teardown (see clearAuth in useAuth). + */ +export function resetWhatsNew(): void { + lastSeenVersion.value = null + popupEnabled.value = true + releases.value = [] + hasMore.value = false + popupOpen.value = false + loading.value = false + error.value = null + prefsLoaded = false + evaluated = false + evaluating = false + dismissedThisSession = false +} + +export function useWhatsNew() { + const { version, loadAppInfo } = useAppInfo() + + const hasUnseen = computed(() => releases.value.length > 0) + + async function loadPrefs(): Promise { + if (prefsLoaded) return + const res = await api('/api/v1/user-preferences/whats-new') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = (await res.json()) as { settings: WhatsNewPreferences } + lastSeenVersion.value = data.settings.lastSeenVersion + popupEnabled.value = data.settings.popupEnabled + prefsLoaded = true + } + + async function persist(settings: Partial): Promise { + return api('/api/v1/user-preferences/whats-new', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings }), + }) + } + + async function saveLastSeen(value: string): Promise { + lastSeenVersion.value = value + releases.value = [] + hasMore.value = false + await persist({ lastSeenVersion: value }).catch(() => {}) + } + + async function evaluate(): Promise { + if (evaluated || evaluating) return + evaluating = true + loading.value = true + error.value = null + try { + await loadAppInfo() + await loadPrefs() + + const current = version.value + if (!isSemverTag(current)) return + + if (lastSeenVersion.value === null) { + await saveLastSeen(current) + return + } + + if (!isNewer(current, lastSeenVersion.value)) return + + const res = await api(`/api/v1/release-notes?since=${encodeURIComponent(lastSeenVersion.value)}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = (await res.json()) as ReleaseNotesResponse + releases.value = data.releases + hasMore.value = data.hasMore + evaluated = true + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to load release notes' + } finally { + evaluating = false + loading.value = false + } + } + + function syncPopup(routeName: string | null | undefined): void { + if ( + shouldShowPopup({ hasUnseen: hasUnseen.value, popupEnabled: popupEnabled.value, dismissedThisSession, routeName, alreadyOpen: popupOpen.value }) + ) { + popupOpen.value = true + } + } + + async function acknowledge(): Promise { + popupOpen.value = false + if (isSemverTag(version.value)) await saveLastSeen(version.value) + } + + function remindLater(): void { + dismissedThisSession = true + popupOpen.value = false + } + + async function markArchiveSeen(): Promise { + await loadAppInfo() + await loadPrefs().catch(() => {}) + const current = version.value + if (isSemverTag(current) && (lastSeenVersion.value === null || isNewer(current, lastSeenVersion.value))) { + await saveLastSeen(current) + } + } + + async function setPopupEnabled(enabled: boolean): Promise { + popupEnabled.value = enabled + const res = await persist({ popupEnabled: enabled }) + if (!res.ok) { + popupEnabled.value = !enabled + throw new Error(`HTTP ${res.status}`) + } + } + + return { + version, + releases, + hasMore, + hasUnseen, + popupOpen, + popupEnabled, + loading, + error, + evaluate, + syncPopup, + acknowledge, + remindLater, + markArchiveSeen, + setPopupEnabled, + loadPrefs, + } +} diff --git a/client/src/features/whats-new/lib/lightbox-state.ts b/client/src/features/whats-new/lib/lightbox-state.ts new file mode 100644 index 000000000..b0fa9ca34 --- /dev/null +++ b/client/src/features/whats-new/lib/lightbox-state.ts @@ -0,0 +1,13 @@ +import { computed, ref } from 'vue' + +const openCount = ref(0) + +/** Returns an unregister fn; lets the popup know not to also close on Escape. */ +export function registerLightbox(): () => void { + openCount.value++ + return () => { + openCount.value = Math.max(0, openCount.value - 1) + } +} + +export const anyLightboxOpen = computed(() => openCount.value > 0) diff --git a/client/src/features/whats-new/lib/markdown.test.ts b/client/src/features/whats-new/lib/markdown.test.ts new file mode 100644 index 000000000..5f2735979 --- /dev/null +++ b/client/src/features/whats-new/lib/markdown.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import { renderChangelogMarkdown } from './markdown' + +describe('renderChangelogMarkdown', () => { + it('renders headings, lists, bold, code and links', () => { + const md = ['### Features', '- add **two-way** sync', '- speed up `jumpRail`', '', '[issue](https://example.com/1)'].join('\n') + const html = renderChangelogMarkdown(md) + expect(html).toContain('

Features

') + expect(html).toContain('