Compare commits

..

2 Commits

Author SHA1 Message Date
LyAhn 55cd3b5aa7 Merge feat/whats-new-updater-ux: What's New screen + updater UX + light-theme fixes
github/actions/ci GitHub Actions CI finished: success
Post-0.1.1 polish queued for the next release (0.1.2):

- What's New: post-update toast that opens an in-app, offline 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 that tells upgrades from fresh installs.
- Updater: the progress toast reappears when an update is started from the
  title-bar indicator or Settings after the prompt was dismissed; Settings
  gains a real download progress bar with a percentage.
- Light theme: fix the recurring subtle-light breakage — neutral surfaces rely
  on the CSS-variable remap instead of light-theme:bg-white, and the green
  action buttons drop the broken light-theme:hover:bg-emerald-200 for an
  override-free emerald tint that auto-themes (updater, What's New, onboarding).
- Debug panel: What's New triggers.
2026-06-23 21:36:35 +01:00
LyAhn c878970180 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).
2026-06-23 21:33:08 +01:00
14 changed files with 537 additions and 8 deletions
+24
View File
@@ -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
+30
View File
@@ -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<Option<String>, 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())
}
+2
View File
@@ -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,
])
+8 -1
View File
@@ -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() {
<SettingsModal />
<FolderPickerModal />
<UpdateToast />
<WhatsNewToast />
<WhatsNewModal />
<OnboardingOverlay />
{import.meta.env.DEV && <DemoPanel />}
</div>
+112
View File
@@ -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;
}
+31
View File
@@ -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<number | null>(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
</button>
</div>
<div className="my-3 h-px bg-amber-400/20" />
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">What's new</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Post-update greeting for v{appVersion ?? "—"}, sourced from the bundled changelog.
</p>
<div className="flex flex-col gap-1.5">
<button className={injectBtn} onClick={showWhatsNewToast}>
Show "What's new" toast
</button>
<button className={injectBtn} onClick={openWhatsNewModal}>
Open "What's new" modal
</button>
<button className={neutralBtn} onClick={resetWhatsNew}>
Reset What's New state
</button>
</div>
</div>
);
}
+35 -2
View File
@@ -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" ? (
<span className="text-amber-300/90">Update check failed: {updateError}</span>
) : updateStatus === "downloading" || updateStatus === "installing" ? (
"Downloading update — the app will restart when it finishes."
<span className="block">
<span className="text-gray-400">
{updateStatus === "installing"
? "Installing update…"
: updateProgress !== null
? `Downloading update — ${Math.round(updateProgress * 100)}%`
: "Downloading update…"}
</span>
<span className="mt-2 block h-1 overflow-hidden rounded-full bg-white/10">
<span
className={`block h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${
updateProgress === null ? "w-full animate-pulse" : ""
}`}
style={updateProgress !== null ? { width: `${Math.round(updateProgress * 100)}%` } : undefined}
/>
</span>
<span className="mt-1 block text-gray-600">The app will restart when it finishes.</span>
</span>
) : (
"Updates are checked quietly at launch and installed only when you choose."
)
@@ -678,7 +698,7 @@ export function SettingsModal() {
>
{updateStatus === "available" ? (
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={() => void installUpdate()}
>
Install &amp; restart
@@ -693,6 +713,19 @@ export function SettingsModal() {
</button>
)}
</SettingsItem>
{getChangelogForVersion(appVersion) ? (
<SettingsItem
label="What's new"
description={`See what changed in Phokus v${appVersion}.`}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={openWhatsNew}
>
View changes
</button>
</SettingsItem>
) : null}
</SettingsGroup>
<SettingsGroup title="Setup">
+1 -1
View File
@@ -31,7 +31,7 @@ export function UpdateToast() {
</p>
<div className="mt-3 flex items-center gap-2">
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={() => void installUpdate()}
>
Install &amp; restart
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useGalleryStore } from "../store";
import { getChangelogForVersion, type ChangelogSection } from "../changelog";
const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md";
// Per-section accent. These all use the standard colour scale, which the
// subtle-light theme re-maps to readable dark shades via CSS variables — so no
// `light-theme:` overrides are needed (or wanted) here. See the theme note in
// index.css: `bg-white`/`text-white` deliberately become *dark* in light mode,
// so neutral surfaces must use the gray scale and trust the remap.
const SECTION_STYLES: Record<string, { label: string; dot: string }> = {
Added: { label: "text-emerald-300", dot: "bg-emerald-400/80" },
Changed: { label: "text-sky-300", dot: "bg-sky-300/80" },
Fixed: { label: "text-amber-300", dot: "bg-amber-400/80" },
Removed: { label: "text-rose-300", dot: "bg-rose-400/80" },
Security: { label: "text-violet-300", dot: "bg-violet-400/80" },
};
const NEUTRAL_STYLE = { label: "text-gray-300", dot: "bg-gray-400/70" };
// Render the small amount of inline markdown the changelog uses: **bold** and
// `code`. Anything else passes through as plain text.
function renderInline(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
const regex = /\*\*(.+?)\*\*|`([^`]+)`/g;
let lastIndex = 0;
let key = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
if (match[1] !== undefined) {
nodes.push(
<strong key={key++} className="font-semibold text-white">
{match[1]}
</strong>,
);
} else if (match[2] !== undefined) {
nodes.push(
<code key={key++} className="rounded bg-white/10 px-1 py-0.5 text-[12px] text-gray-200">
{match[2]}
</code>,
);
}
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
function Section({ section }: { section: ChangelogSection }) {
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE;
const [open, setOpen] = useState(true);
return (
<section>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center gap-2 text-left"
aria-expanded={open}
>
<svg
className={`h-3 w-3 shrink-0 text-gray-600 transition-transform ${open ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
<span className={`text-[11px] font-semibold uppercase tracking-[0.1em] ${style.label}`}>{section.title}</span>
<span className="text-[11px] font-medium text-gray-600">{section.items.length}</span>
</button>
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18 }}
className="overflow-hidden"
>
<ul className="mt-2.5 space-y-2.5 pl-5">
{section.items.map((item, index) => (
<li key={index} className="flex gap-3">
<span className={`mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full ${style.dot}`} />
<p className="text-[13px] leading-relaxed text-gray-400">
{item.lead ? (
<>
<span className="font-semibold text-gray-100">{item.lead}</span>
{item.body ? <span> {renderInline(item.body)}</span> : null}
</>
) : (
renderInline(item.body)
)}
</p>
</li>
))}
</ul>
</motion.div>
) : null}
</AnimatePresence>
</section>
);
}
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 (
<AnimatePresence>
{whatsNewOpen ? (
<motion.div
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
onClick={closeWhatsNew}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<motion.div
className="relative flex max-h-[min(82vh,760px)] w-[min(88vw,560px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
onClick={(event) => 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 }}
>
<div className="flex items-start justify-between gap-4 border-b border-white/[0.07] px-6 py-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-emerald-300">What's new</p>
<h3 className="mt-1 text-lg font-semibold text-white">
Phokus v{entry?.version ?? appVersion ?? "—"}
</h3>
{entry?.date ? <p className="mt-0.5 text-xs text-gray-600">Released {entry.date}</p> : null}
</div>
<button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={closeWhatsNew}
title="Close"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
{entry && entry.sections.length > 0 ? (
entry.sections.map((section) => <Section key={section.title} section={section} />)
) : (
<p className="text-sm text-gray-500">
Release notes for this version aren't available in-app. See the full changelog on GitHub.
</p>
)}
</div>
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
<button
type="button"
onClick={() => void openUrl(CHANGELOG_URL)}
className="text-xs text-gray-500 transition-colors hover:text-gray-300"
>
Full changelog
</button>
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3.5 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={closeWhatsNew}
>
Got it
</button>
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
+44
View File
@@ -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 (
<AnimatePresence>
{visible ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.18 }}
className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-emerald-400/20 bg-gray-900 p-4 shadow-xl"
>
<p className="text-sm font-medium text-white">What's new in Phokus v{whatsNewToast}</p>
<p className="mt-1 text-xs text-gray-500">See what's changed in this version.</p>
<div className="mt-3 flex items-center gap-2">
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={openWhatsNew}
>
What's new
</button>
<button
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
onClick={dismissWhatsNewToast}
>
Dismiss
</button>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}
@@ -103,7 +103,7 @@ export function OnboardingOverlay() {
Back
</button>
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-4 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-4 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={() => (isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))}
>
{isLast ? "Finish" : "Next"}
+1 -1
View File
@@ -37,7 +37,7 @@ export function StepAddLibrary() {
)}
</div>
<button
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
onClick={handlePick}
>
{folders.length > 0 ? "Add another folder" : "Choose a folder"}
+1 -1
View File
@@ -142,7 +142,7 @@ export function StepWelcome() {
type="button"
className={`rounded-md border p-2 text-left transition-colors ${
selected
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/40 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/40"
: "border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
}`}
onClick={() => setTheme(option.value)}
+50 -1
View File
@@ -6,6 +6,7 @@ import { getVersion } from "@tauri-apps/api/app";
import { check, Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { notifyTaskComplete } from "./notifications";
import { getChangelogForVersion } from "./changelog";
// Per-folder debounce timers for batching notifications.
// Keyed as `${folderId}:embedding` or `${folderId}:tagging`.
@@ -378,6 +379,11 @@ interface GalleryState {
updateProgress: number | null; // 0..1 download progress, null while size unknown
updateError: string | null;
updateDismissed: boolean;
// "What's New" greeting after a version change. `whatsNewToast` holds the
// version to advertise in the corner toast (null = hidden); `whatsNewOpen`
// controls the full changelog modal.
whatsNewOpen: boolean;
whatsNewToast: string | null;
ffmpegStatus: FfmpegStatus;
ffmpegProgress: { downloaded_bytes: number; total_bytes: number } | null;
@@ -479,6 +485,10 @@ interface GalleryState {
checkForUpdates: (options?: { quiet?: boolean }) => Promise<void>;
installUpdate: () => Promise<void>;
dismissUpdate: () => void;
initWhatsNew: () => Promise<void>;
openWhatsNew: () => void;
closeWhatsNew: () => void;
dismissWhatsNewToast: () => void;
loadFfmpegStatus: () => Promise<void>;
retryFfmpegDownload: () => Promise<void>;
loadOnboardingCompleted: () => Promise<void>;
@@ -810,6 +820,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
updateProgress: null,
updateError: null,
updateDismissed: false,
whatsNewOpen: false,
whatsNewToast: null,
ffmpegStatus: "unknown",
ffmpegProgress: null,
@@ -1801,7 +1813,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const update = pendingUpdate;
if (!update || get().updateStatus !== "available") return;
set({ updateStatus: "downloading", updateProgress: null, updateError: null });
// Clearing the dismissed flag re-surfaces the progress toast: the user may
// have clicked "Later" on the prompt and then triggered the install from the
// title-bar button or Settings, and they should still see download progress.
set({ updateStatus: "downloading", updateProgress: null, updateError: null, updateDismissed: false });
try {
let contentLength: number | null = null;
let downloaded = 0;
@@ -1830,6 +1845,40 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
dismissUpdate: () => set({ updateDismissed: true }),
initWhatsNew: async () => {
try {
const current = await getVersion();
const lastSeen = (await invoke<string | null>("get_last_seen_version")) || null;
// Already greeted this version — nothing to do, and no need to rewrite.
if (lastSeen === current) return;
let shouldShow: boolean;
if (lastSeen) {
// A recorded earlier version means this launch is a genuine upgrade.
shouldShow = true;
} else {
// No record yet. Fresh installs are covered by the welcome tour, so only
// greet users who have already completed onboarding (i.e. upgraded into
// this feature) rather than someone opening the app for the first time.
shouldShow = await invoke<boolean>("get_onboarding_completed").catch(() => false);
}
// Only surface the prompt if we actually have notes for this version.
if (shouldShow && getChangelogForVersion(current)) {
set({ whatsNewToast: current });
}
await invoke("set_last_seen_version", { version: current }).catch(() => {});
} catch {
// Non-fatal: the greeting is a nicety, never block startup on it.
}
},
openWhatsNew: () => set({ whatsNewOpen: true, whatsNewToast: null }),
closeWhatsNew: () => set({ whatsNewOpen: false }),
dismissWhatsNewToast: () => set({ whatsNewToast: null }),
loadFfmpegStatus: async () => {
try {
const status = await invoke<{ installed: boolean; downloading: boolean; failed: boolean }>(