From c878970180e358fa4820b48aff191044e875aa6e Mon Sep 17 00:00:00 2001 From: LyAhn Date: Tue, 23 Jun 2026 21:33:08 +0100 Subject: [PATCH] feat: What's New screen + updater progress, plus light-theme fixes Post-update UX for the next release (0.1.2): - What's New: after a version change, greet the user with a toast that opens an in-app release-notes screen (collapsible Added/Changed/Fixed sections) sourced from the bundled CHANGELOG.md, reopenable from Settings -> Updates. Backed by a last_seen_version settings file (get/set_last_seen_version commands) that distinguishes upgrades from fresh installs. - Updater: the download/install progress toast now reappears when an update is started from the title-bar indicator or Settings after the prompt was dismissed; Settings -> Updates gains a real progress bar with a percentage. - Light theme: fix the recurring subtle-light breakage. Neutral surfaces now rely on the CSS-variable remap instead of light-theme:bg-white (which forced surfaces dark because --color-white is remapped dark); the green action buttons drop the broken light-theme:hover:bg-emerald-200 (remapped dark, unreadable on hover) for an override-free emerald-500 tint that auto-themes, across the updater, What's New, and onboarding. - Debug panel: add What's New triggers (toast / modal / reset). --- CHANGELOG.md | 24 +++ src-tauri/src/commands.rs | 30 +++ src-tauri/src/lib.rs | 2 + src/App.tsx | 9 +- src/changelog.ts | 112 ++++++++++ src/components/DemoPanel.tsx | 31 +++ src/components/SettingsModal.tsx | 37 +++- src/components/UpdateToast.tsx | 2 +- src/components/WhatsNewModal.tsx | 197 ++++++++++++++++++ src/components/WhatsNewToast.tsx | 44 ++++ .../onboarding/OnboardingOverlay.tsx | 2 +- src/components/onboarding/StepAddLibrary.tsx | 2 +- src/components/onboarding/StepWelcome.tsx | 2 +- src/store.ts | 51 ++++- 14 files changed, 537 insertions(+), 8 deletions(-) create mode 100644 src/changelog.ts create mode 100644 src/components/WhatsNewModal.tsx create mode 100644 src/components/WhatsNewToast.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 953b710..0641712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to Phokus are documented here. The format is based on aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (0.x: anything may change between minor versions). +## [Unreleased] + +### Added + +- **What's New** — after updating, Phokus now greets you with a "What's new" + toast that opens an in-app release-notes screen for the new version, with the + changes grouped into collapsible Added / Changed / Fixed sections. It's + sourced from the bundled changelog (so it works offline) and can be reopened + any time from Settings → Updates → What's new. + +### Changed + +- The updater now shows a real download progress bar with a percentage in + Settings → Updates (previously it only said "Downloading"). + +### Fixed + +- The update download/install progress toast now reappears when you start an + update from the title-bar indicator or Settings after dismissing the earlier + "Update available" prompt — previously progress only showed in Settings. +- Subtle Light theme — fixed several surfaces and buttons that stayed dark or + became unreadable on hover, including new dialogs and the green action buttons + across the updater and onboarding. + ## [0.1.1] — 2026-06-23 ### Added diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 06053ab..f6c0da1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2430,3 +2430,33 @@ pub async fn set_onboarding_completed(app: AppHandle, completed: bool) -> Result ) .map_err(|e| e.to_string()) } + +const LAST_SEEN_VERSION_FILE: &str = "settings/last_seen_version.txt"; + +/// The app version recorded on the previous launch. `None` when the file is +/// absent (fresh install, or first launch since this was introduced) — the +/// frontend uses that to decide whether to surface the "What's New" prompt. +#[tauri::command] +pub async fn get_last_seen_version(app: AppHandle) -> Result, String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(LAST_SEEN_VERSION_FILE); + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let trimmed = content.trim(); + Ok(if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }) +} + +#[tauri::command] +pub async fn set_last_seen_version(app: AppHandle, version: String) -> Result<(), String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let settings_dir = app_dir.join("settings"); + std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?; + std::fs::write(settings_dir.join("last_seen_version.txt"), version.trim()) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9ffb038..dec5ccc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -208,6 +208,8 @@ pub fn run() { commands::retry_ffmpeg_download, commands::get_onboarding_completed, commands::set_onboarding_completed, + commands::get_last_seen_version, + commands::set_last_seen_version, commands::get_notifications_paused, commands::set_notifications_paused, ]) diff --git a/src/App.tsx b/src/App.tsx index fa0adb3..45bc904 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,8 @@ import { TitleBar } from "./components/TitleBar"; import { SettingsModal } from "./components/SettingsModal"; import { FolderPickerModal } from "./components/FolderPickerModal"; import { UpdateToast } from "./components/UpdateToast"; +import { WhatsNewToast } from "./components/WhatsNewToast"; +import { WhatsNewModal } from "./components/WhatsNewModal"; import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay"; import { DemoPanel } from "./components/DemoPanel"; import { initializeNotifications } from "./notifications"; @@ -29,15 +31,18 @@ export default function App() { const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus); const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted); + const initWhatsNew = useGalleryStore((state) => state.initWhatsNew); const activeView = useGalleryStore((state) => state.activeView); useEffect(() => { void initializeNotifications(); void loadMutedFolderIds(); void loadNotificationsPaused(); - void loadAppVersion(); void loadFfmpegStatus(); void loadOnboardingCompleted(); + // Load the app version first so the What's New toast/modal (which read + // appVersion from the store) have it before the greeting can appear. + void loadAppVersion().then(() => initWhatsNew()); // Quiet launch check — dev builds have no signed artifacts to update to. if (import.meta.env.PROD) { void checkForUpdates({ quiet: true }); @@ -96,6 +101,8 @@ export default function App() { + + {import.meta.env.DEV && } diff --git a/src/changelog.ts b/src/changelog.ts new file mode 100644 index 0000000..1e3a112 --- /dev/null +++ b/src/changelog.ts @@ -0,0 +1,112 @@ +// Parses the project CHANGELOG.md (imported raw at build time) into structured +// data so the "What's New" UI can render it nicely instead of dumping markdown. +// Keeping the changelog as the single source of truth means there's no separate +// per-release copy to maintain — whatever ships in CHANGELOG.md is what users see. +import changelogRaw from "../CHANGELOG.md?raw"; + +export interface ChangelogItem { + /** The bold lead-in at the start of a bullet (e.g. "Custom multi-folder picker"), if any. */ + lead: string | null; + /** The remaining descriptive text. May still contain inline `code` / **bold** markers. */ + body: string; +} + +export interface ChangelogSection { + /** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */ + title: string; + items: ChangelogItem[]; +} + +export interface ChangelogEntry { + version: string; + date: string | null; + sections: ChangelogSection[]; +} + +// "## [0.1.1] — 2026-06-23" / "## [Unreleased]" +const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/; +// "### Added" +const SECTION_HEADING = /^###\s+(.+?)\s*$/; +// "- bullet text" +const BULLET = /^[-*]\s+(.*)$/; +// Leading "**Title**" optionally followed by an em dash, used as the item's lead-in. +// (Item text is whitespace-collapsed before matching, so no dotAll flag needed.) +const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/; + +function toItem(text: string): ChangelogItem { + const collapsed = text.replace(/\s+/g, " ").trim(); + const match = collapsed.match(LEAD); + if (match) { + return { lead: match[1].trim(), body: match[2].trim() }; + } + return { lead: null, body: collapsed }; +} + +function parseChangelog(raw: string): ChangelogEntry[] { + const lines = raw.split(/\r?\n/); + const entries: ChangelogEntry[] = []; + + let entry: ChangelogEntry | null = null; + let section: ChangelogSection | null = null; + let buffer: string[] = []; + + const flushItem = () => { + if (section && buffer.length > 0) { + section.items.push(toItem(buffer.join(" "))); + } + buffer = []; + }; + + for (const line of lines) { + const versionMatch = line.match(VERSION_HEADING); + if (versionMatch) { + flushItem(); + section = null; + entry = { version: versionMatch[1].trim(), date: versionMatch[2]?.trim() ?? null, sections: [] }; + entries.push(entry); + continue; + } + + // Stop collecting once we leave the changelog body (e.g. link-reference defs at EOF). + if (!entry) continue; + + const sectionMatch = line.match(SECTION_HEADING); + if (sectionMatch) { + flushItem(); + section = { title: sectionMatch[1].trim(), items: [] }; + entry.sections.push(section); + continue; + } + + const bulletMatch = line.match(BULLET); + if (bulletMatch) { + flushItem(); + buffer.push(bulletMatch[1]); + continue; + } + + if (line.trim() === "") { + // A blank line ends a (possibly wrapped) multi-line bullet. + flushItem(); + continue; + } + + // Indented continuation of the current wrapped bullet. + if (buffer.length > 0) { + buffer.push(line.trim()); + } + } + + flushItem(); + return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) })); +} + +const ENTRIES = parseChangelog(changelogRaw); + +export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null { + if (!version) return null; + const normalized = version.replace(/^v/, ""); + // Never surface the in-progress [Unreleased] section to users. + if (normalized.toLowerCase() === "unreleased") return null; + return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null; +} diff --git a/src/components/DemoPanel.tsx b/src/components/DemoPanel.tsx index b62c9a8..e90463d 100644 --- a/src/components/DemoPanel.tsx +++ b/src/components/DemoPanel.tsx @@ -42,6 +42,7 @@ const DEMO_UPDATE_VERSION = "0.2.0"; export function DemoPanel() { const folders = useGalleryStore((state) => state.folders); + const appVersion = useGalleryStore((state) => state.appVersion); const [open, setOpen] = useState(false); const downloadTimer = useRef(null); @@ -160,6 +161,18 @@ export function DemoPanel() { }); }; + // --- What's New flow ------------------------------------------------------ + // Drives the post-update greeting without needing a real version change. The + // toast + modal pull their content from the bundled changelog for the running + // app version, so the current release's notes are what render. + + const showWhatsNewToast = () => + useGalleryStore.setState({ whatsNewToast: appVersion ?? DEMO_UPDATE_VERSION, whatsNewOpen: false }); + + const openWhatsNewModal = () => useGalleryStore.setState({ whatsNewOpen: true, whatsNewToast: null }); + + const resetWhatsNew = () => useGalleryStore.setState({ whatsNewOpen: false, whatsNewToast: null }); + if (!open) return null; const injectBtn = @@ -215,6 +228,24 @@ export function DemoPanel() { Reset updater state + +
+ +

What's new

+

+ Post-update greeting for v{appVersion ?? "—"}, sourced from the bundled changelog. +

+
+ + + +
); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index ba50dca..aaf2e88 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; import { FfmpegStatusRow } from "./onboarding/StepWelcome"; import { ThemedDropdown } from "./ThemedDropdown"; +import { getChangelogForVersion } from "../changelog"; type SettingsSection = "workspace" | "general"; @@ -192,9 +193,11 @@ export function SettingsModal() { const appVersion = useGalleryStore((state) => state.appVersion); const updateStatus = useGalleryStore((state) => state.updateStatus); const updateVersion = useGalleryStore((state) => state.updateVersion); + const updateProgress = useGalleryStore((state) => state.updateProgress); const updateError = useGalleryStore((state) => state.updateError); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); const installUpdate = useGalleryStore((state) => state.installUpdate); + const openWhatsNew = useGalleryStore((state) => state.openWhatsNew); const openOnboarding = useGalleryStore((state) => state.openOnboarding); const theme = useGalleryStore((state) => state.theme); const setTheme = useGalleryStore((state) => state.setTheme); @@ -670,7 +673,24 @@ export function SettingsModal() { updateStatus === "error" ? ( Update check failed: {updateError} ) : updateStatus === "downloading" || updateStatus === "installing" ? ( - "Downloading update — the app will restart when it finishes." + + + {updateStatus === "installing" + ? "Installing update…" + : updateProgress !== null + ? `Downloading update — ${Math.round(updateProgress * 100)}%` + : "Downloading update…"} + + + + + The app will restart when it finishes. + ) : ( "Updates are checked quietly at launch and installed only when you choose." ) @@ -678,7 +698,7 @@ export function SettingsModal() { > {updateStatus === "available" ? ( )} + {getChangelogForVersion(appVersion) ? ( + + + + ) : null} diff --git a/src/components/UpdateToast.tsx b/src/components/UpdateToast.tsx index e569768..ad1bc0a 100644 --- a/src/components/UpdateToast.tsx +++ b/src/components/UpdateToast.tsx @@ -31,7 +31,7 @@ export function UpdateToast() {

+ + {open ? ( + +
    + {section.items.map((item, index) => ( +
  • + +

    + {item.lead ? ( + <> + {item.lead} + {item.body ? — {renderInline(item.body)} : null} + + ) : ( + renderInline(item.body) + )} +

    +
  • + ))} +
+
+ ) : null} +
+ + ); +} + +export function WhatsNewModal() { + const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen); + const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew); + const appVersion = useGalleryStore((s) => s.appVersion); + + const entry = getChangelogForVersion(appVersion); + + useEffect(() => { + if (!whatsNewOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") closeWhatsNew(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [whatsNewOpen, closeWhatsNew]); + + return ( + + {whatsNewOpen ? ( + + event.stopPropagation()} + initial={{ opacity: 0, scale: 0.97, y: 8 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.98, y: 6 }} + transition={{ duration: 0.16 }} + > +
+
+

What's new

+

+ Phokus v{entry?.version ?? appVersion ?? "—"} +

+ {entry?.date ?

Released {entry.date}

: null} +
+ +
+ +
+ {entry && entry.sections.length > 0 ? ( + entry.sections.map((section) =>
) + ) : ( +

+ Release notes for this version aren't available in-app. See the full changelog on GitHub. +

+ )} +
+ +
+ + +
+
+
+ ) : null} +
+ ); +} diff --git a/src/components/WhatsNewToast.tsx b/src/components/WhatsNewToast.tsx new file mode 100644 index 0000000..578c7e8 --- /dev/null +++ b/src/components/WhatsNewToast.tsx @@ -0,0 +1,44 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { useGalleryStore } from "../store"; + +// Shown once on the first launch after an update, inviting the user to read the +// changelog. Distinct from UpdateToast (which drives the download/install flow). +export function WhatsNewToast() { + const whatsNewToast = useGalleryStore((s) => s.whatsNewToast); + const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen); + const openWhatsNew = useGalleryStore((s) => s.openWhatsNew); + const dismissWhatsNewToast = useGalleryStore((s) => s.dismissWhatsNewToast); + + const visible = whatsNewToast !== null && !whatsNewOpen; + + return ( + + {visible ? ( + +

What's new in Phokus v{whatsNewToast}

+

See what's changed in this version.

+
+ + +
+
+ ) : null} +
+ ); +} diff --git a/src/components/onboarding/OnboardingOverlay.tsx b/src/components/onboarding/OnboardingOverlay.tsx index 4c5992d..6ffd6ea 100644 --- a/src/components/onboarding/OnboardingOverlay.tsx +++ b/src/components/onboarding/OnboardingOverlay.tsx @@ -103,7 +103,7 @@ export function OnboardingOverlay() { Back