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).
This commit is contained in:
@@ -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)
|
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||||
(0.x: anything may change between minor versions).
|
(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
|
## [0.1.1] — 2026-06-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2430,3 +2430,33 @@ pub async fn set_onboarding_completed(app: AppHandle, completed: bool) -> Result
|
|||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())
|
.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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ pub fn run() {
|
|||||||
commands::retry_ffmpeg_download,
|
commands::retry_ffmpeg_download,
|
||||||
commands::get_onboarding_completed,
|
commands::get_onboarding_completed,
|
||||||
commands::set_onboarding_completed,
|
commands::set_onboarding_completed,
|
||||||
|
commands::get_last_seen_version,
|
||||||
|
commands::set_last_seen_version,
|
||||||
commands::get_notifications_paused,
|
commands::get_notifications_paused,
|
||||||
commands::set_notifications_paused,
|
commands::set_notifications_paused,
|
||||||
])
|
])
|
||||||
|
|||||||
+8
-1
@@ -12,6 +12,8 @@ import { TitleBar } from "./components/TitleBar";
|
|||||||
import { SettingsModal } from "./components/SettingsModal";
|
import { SettingsModal } from "./components/SettingsModal";
|
||||||
import { FolderPickerModal } from "./components/FolderPickerModal";
|
import { FolderPickerModal } from "./components/FolderPickerModal";
|
||||||
import { UpdateToast } from "./components/UpdateToast";
|
import { UpdateToast } from "./components/UpdateToast";
|
||||||
|
import { WhatsNewToast } from "./components/WhatsNewToast";
|
||||||
|
import { WhatsNewModal } from "./components/WhatsNewModal";
|
||||||
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
|
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
|
||||||
import { DemoPanel } from "./components/DemoPanel";
|
import { DemoPanel } from "./components/DemoPanel";
|
||||||
import { initializeNotifications } from "./notifications";
|
import { initializeNotifications } from "./notifications";
|
||||||
@@ -29,15 +31,18 @@ export default function App() {
|
|||||||
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
|
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
|
||||||
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus);
|
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus);
|
||||||
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted);
|
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted);
|
||||||
|
const initWhatsNew = useGalleryStore((state) => state.initWhatsNew);
|
||||||
const activeView = useGalleryStore((state) => state.activeView);
|
const activeView = useGalleryStore((state) => state.activeView);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void initializeNotifications();
|
void initializeNotifications();
|
||||||
void loadMutedFolderIds();
|
void loadMutedFolderIds();
|
||||||
void loadNotificationsPaused();
|
void loadNotificationsPaused();
|
||||||
void loadAppVersion();
|
|
||||||
void loadFfmpegStatus();
|
void loadFfmpegStatus();
|
||||||
void loadOnboardingCompleted();
|
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.
|
// Quiet launch check — dev builds have no signed artifacts to update to.
|
||||||
if (import.meta.env.PROD) {
|
if (import.meta.env.PROD) {
|
||||||
void checkForUpdates({ quiet: true });
|
void checkForUpdates({ quiet: true });
|
||||||
@@ -96,6 +101,8 @@ export default function App() {
|
|||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<FolderPickerModal />
|
<FolderPickerModal />
|
||||||
<UpdateToast />
|
<UpdateToast />
|
||||||
|
<WhatsNewToast />
|
||||||
|
<WhatsNewModal />
|
||||||
<OnboardingOverlay />
|
<OnboardingOverlay />
|
||||||
{import.meta.env.DEV && <DemoPanel />}
|
{import.meta.env.DEV && <DemoPanel />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ const DEMO_UPDATE_VERSION = "0.2.0";
|
|||||||
|
|
||||||
export function DemoPanel() {
|
export function DemoPanel() {
|
||||||
const folders = useGalleryStore((state) => state.folders);
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
|
const appVersion = useGalleryStore((state) => state.appVersion);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const downloadTimer = useRef<number | null>(null);
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
const injectBtn =
|
const injectBtn =
|
||||||
@@ -215,6 +228,24 @@ export function DemoPanel() {
|
|||||||
Reset updater state
|
Reset updater state
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
|
import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
|
||||||
import { FfmpegStatusRow } from "./onboarding/StepWelcome";
|
import { FfmpegStatusRow } from "./onboarding/StepWelcome";
|
||||||
import { ThemedDropdown } from "./ThemedDropdown";
|
import { ThemedDropdown } from "./ThemedDropdown";
|
||||||
|
import { getChangelogForVersion } from "../changelog";
|
||||||
|
|
||||||
type SettingsSection = "workspace" | "general";
|
type SettingsSection = "workspace" | "general";
|
||||||
|
|
||||||
@@ -192,9 +193,11 @@ export function SettingsModal() {
|
|||||||
const appVersion = useGalleryStore((state) => state.appVersion);
|
const appVersion = useGalleryStore((state) => state.appVersion);
|
||||||
const updateStatus = useGalleryStore((state) => state.updateStatus);
|
const updateStatus = useGalleryStore((state) => state.updateStatus);
|
||||||
const updateVersion = useGalleryStore((state) => state.updateVersion);
|
const updateVersion = useGalleryStore((state) => state.updateVersion);
|
||||||
|
const updateProgress = useGalleryStore((state) => state.updateProgress);
|
||||||
const updateError = useGalleryStore((state) => state.updateError);
|
const updateError = useGalleryStore((state) => state.updateError);
|
||||||
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
|
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
|
||||||
const installUpdate = useGalleryStore((state) => state.installUpdate);
|
const installUpdate = useGalleryStore((state) => state.installUpdate);
|
||||||
|
const openWhatsNew = useGalleryStore((state) => state.openWhatsNew);
|
||||||
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
|
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
|
||||||
const theme = useGalleryStore((state) => state.theme);
|
const theme = useGalleryStore((state) => state.theme);
|
||||||
const setTheme = useGalleryStore((state) => state.setTheme);
|
const setTheme = useGalleryStore((state) => state.setTheme);
|
||||||
@@ -670,7 +673,24 @@ export function SettingsModal() {
|
|||||||
updateStatus === "error" ? (
|
updateStatus === "error" ? (
|
||||||
<span className="text-amber-300/90">Update check failed: {updateError}</span>
|
<span className="text-amber-300/90">Update check failed: {updateError}</span>
|
||||||
) : updateStatus === "downloading" || updateStatus === "installing" ? (
|
) : 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."
|
"Updates are checked quietly at launch and installed only when you choose."
|
||||||
)
|
)
|
||||||
@@ -678,7 +698,7 @@ export function SettingsModal() {
|
|||||||
>
|
>
|
||||||
{updateStatus === "available" ? (
|
{updateStatus === "available" ? (
|
||||||
<button
|
<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()}
|
onClick={() => void installUpdate()}
|
||||||
>
|
>
|
||||||
Install & restart
|
Install & restart
|
||||||
@@ -693,6 +713,19 @@ export function SettingsModal() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</SettingsItem>
|
</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>
|
||||||
|
|
||||||
<SettingsGroup title="Setup">
|
<SettingsGroup title="Setup">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function UpdateToast() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<button
|
<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()}
|
onClick={() => void installUpdate()}
|
||||||
>
|
>
|
||||||
Install & restart
|
Install & restart
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
Back
|
||||||
</button>
|
</button>
|
||||||
<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))}
|
onClick={() => (isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))}
|
||||||
>
|
>
|
||||||
{isLast ? "Finish" : "Next"}
|
{isLast ? "Finish" : "Next"}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function StepAddLibrary() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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}
|
onClick={handlePick}
|
||||||
>
|
>
|
||||||
{folders.length > 0 ? "Add another folder" : "Choose a folder"}
|
{folders.length > 0 ? "Add another folder" : "Choose a folder"}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function StepWelcome() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={`rounded-md border p-2 text-left transition-colors ${
|
className={`rounded-md border p-2 text-left transition-colors ${
|
||||||
selected
|
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"
|
: "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)}
|
onClick={() => setTheme(option.value)}
|
||||||
|
|||||||
+50
-1
@@ -6,6 +6,7 @@ import { getVersion } from "@tauri-apps/api/app";
|
|||||||
import { check, Update } from "@tauri-apps/plugin-updater";
|
import { check, Update } from "@tauri-apps/plugin-updater";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
import { notifyTaskComplete } from "./notifications";
|
import { notifyTaskComplete } from "./notifications";
|
||||||
|
import { getChangelogForVersion } from "./changelog";
|
||||||
|
|
||||||
// Per-folder debounce timers for batching notifications.
|
// Per-folder debounce timers for batching notifications.
|
||||||
// Keyed as `${folderId}:embedding` or `${folderId}:tagging`.
|
// Keyed as `${folderId}:embedding` or `${folderId}:tagging`.
|
||||||
@@ -378,6 +379,11 @@ interface GalleryState {
|
|||||||
updateProgress: number | null; // 0..1 download progress, null while size unknown
|
updateProgress: number | null; // 0..1 download progress, null while size unknown
|
||||||
updateError: string | null;
|
updateError: string | null;
|
||||||
updateDismissed: boolean;
|
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;
|
ffmpegStatus: FfmpegStatus;
|
||||||
ffmpegProgress: { downloaded_bytes: number; total_bytes: number } | null;
|
ffmpegProgress: { downloaded_bytes: number; total_bytes: number } | null;
|
||||||
@@ -479,6 +485,10 @@ interface GalleryState {
|
|||||||
checkForUpdates: (options?: { quiet?: boolean }) => Promise<void>;
|
checkForUpdates: (options?: { quiet?: boolean }) => Promise<void>;
|
||||||
installUpdate: () => Promise<void>;
|
installUpdate: () => Promise<void>;
|
||||||
dismissUpdate: () => void;
|
dismissUpdate: () => void;
|
||||||
|
initWhatsNew: () => Promise<void>;
|
||||||
|
openWhatsNew: () => void;
|
||||||
|
closeWhatsNew: () => void;
|
||||||
|
dismissWhatsNewToast: () => void;
|
||||||
loadFfmpegStatus: () => Promise<void>;
|
loadFfmpegStatus: () => Promise<void>;
|
||||||
retryFfmpegDownload: () => Promise<void>;
|
retryFfmpegDownload: () => Promise<void>;
|
||||||
loadOnboardingCompleted: () => Promise<void>;
|
loadOnboardingCompleted: () => Promise<void>;
|
||||||
@@ -810,6 +820,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
updateProgress: null,
|
updateProgress: null,
|
||||||
updateError: null,
|
updateError: null,
|
||||||
updateDismissed: false,
|
updateDismissed: false,
|
||||||
|
whatsNewOpen: false,
|
||||||
|
whatsNewToast: null,
|
||||||
|
|
||||||
ffmpegStatus: "unknown",
|
ffmpegStatus: "unknown",
|
||||||
ffmpegProgress: null,
|
ffmpegProgress: null,
|
||||||
@@ -1801,7 +1813,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
const update = pendingUpdate;
|
const update = pendingUpdate;
|
||||||
if (!update || get().updateStatus !== "available") return;
|
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 {
|
try {
|
||||||
let contentLength: number | null = null;
|
let contentLength: number | null = null;
|
||||||
let downloaded = 0;
|
let downloaded = 0;
|
||||||
@@ -1830,6 +1845,40 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
|
|
||||||
dismissUpdate: () => set({ updateDismissed: true }),
|
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 () => {
|
loadFfmpegStatus: async () => {
|
||||||
try {
|
try {
|
||||||
const status = await invoke<{ installed: boolean; downloading: boolean; failed: boolean }>(
|
const status = await invoke<{ installed: boolean; downloading: boolean; failed: boolean }>(
|
||||||
|
|||||||
Reference in New Issue
Block a user