Compare commits
3 Commits
v0.1.1
...
55cd3b5aa7
| Author | SHA1 | Date | |
|---|---|---|---|
| 55cd3b5aa7 | |||
| c878970180 | |||
| 1a95e31f78 |
@@ -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
|
||||
|
||||
@@ -59,6 +59,6 @@ for the full list.
|
||||
## Checksums
|
||||
|
||||
```
|
||||
SHA-256 (Phokus_0.1.1_x64-setup.exe) = <fill in>
|
||||
SHA-256 (Phokus_0.1.1_x64-cuda-setup.exe) = <fill in>
|
||||
SHA-256 (Phokus_0.1.1_x64-setup.exe) = 1c19cbeb77f38a44149380c42c76b633add65777d317e1f3ff7e45d96d12d287
|
||||
SHA-256 (Phokus_0.1.1_x64-cuda-setup.exe) = a7337ef5ee0478a785b48acc8012e8fc5c957341161d6103409213ad78eb845f
|
||||
```
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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 & 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
|
||||
</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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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 }>(
|
||||
|
||||
Reference in New Issue
Block a user