feat(updates): titlebar update indicator + dev injectors for the update flow
- light up the Phokus iris's focal point (pulsing amber) in the titlebar when an update is pending; click goes straight to install, with a fast custom tooltip (native title delay was too slow) - PhokusMark gains an optional dotClassName to render the central focal point - DemoPanel: inject every updater state (available/downloading/installing/ error/up-to-date), which is otherwise unreachable without a remote latest.json
This commit is contained in:
+131
-17
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { FolderJobProgress, useGalleryStore } from "../store";
|
import { FolderJobProgress, useGalleryStore } from "../store";
|
||||||
|
|
||||||
// Dev-only screenshot/demo helper. Injects frozen UI states that are otherwise
|
// Dev-only screenshot/demo helper. Injects frozen UI states that are otherwise
|
||||||
// too transient to capture (e.g. the background-worker pipeline, which drains
|
// too transient (or unreachable) to capture: the background-worker pipeline,
|
||||||
// in under a second on a fast machine). Gated by import.meta.env.DEV in App.tsx
|
// which drains in under a second, and the auto-updater flow, which never fires
|
||||||
// so the whole thing is tree-shaken out of production builds.
|
// locally because there's no remote latest.json to check against.
|
||||||
|
// Gated by import.meta.env.DEV in App.tsx so the whole thing is tree-shaken out
|
||||||
|
// of production builds.
|
||||||
//
|
//
|
||||||
// Toggle the controls with Ctrl+Shift+D. Injected state persists in the store
|
// Toggle the controls with Ctrl+Shift+D. Injected state persists in the store
|
||||||
// while the panel is hidden, so: open → inject → hide → screenshot.
|
// while the panel is hidden, so: open → inject → hide → screenshot.
|
||||||
@@ -36,9 +38,12 @@ const BUSY_PRESETS: Partial<FolderJobProgress>[] = [
|
|||||||
{ embedding_ready: 40, tagging_ready: 18, tagging_pending: 22 },
|
{ embedding_ready: 40, tagging_ready: 18, tagging_pending: 22 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const downloadTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
@@ -51,6 +56,16 @@ export function DemoPanel() {
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const stopTimer = () => {
|
||||||
|
if (downloadTimer.current !== null) {
|
||||||
|
window.clearInterval(downloadTimer.current);
|
||||||
|
downloadTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop any running download animation when the panel unmounts.
|
||||||
|
useEffect(() => stopTimer, []);
|
||||||
|
|
||||||
const injectBusy = () => {
|
const injectBusy = () => {
|
||||||
const progress: Record<number, FolderJobProgress> = {};
|
const progress: Record<number, FolderJobProgress> = {};
|
||||||
folders.slice(0, BUSY_PRESETS.length).forEach((folder, index) => {
|
folders.slice(0, BUSY_PRESETS.length).forEach((folder, index) => {
|
||||||
@@ -71,36 +86,135 @@ export function DemoPanel() {
|
|||||||
|
|
||||||
const clear = () => useGalleryStore.setState({ mediaJobProgress: {} });
|
const clear = () => useGalleryStore.setState({ mediaJobProgress: {} });
|
||||||
|
|
||||||
|
// --- Updater flow ---------------------------------------------------------
|
||||||
|
// These drive the real UpdateToast + Settings "About" row. The Install button
|
||||||
|
// stays inert (there's no real pendingUpdate), so this validates the
|
||||||
|
// presentation only — see DemoPanel's header note for the real e2e path.
|
||||||
|
|
||||||
|
const updateAvailable = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({
|
||||||
|
updateStatus: "available",
|
||||||
|
updateVersion: DEMO_UPDATE_VERSION,
|
||||||
|
updateProgress: null,
|
||||||
|
updateError: null,
|
||||||
|
updateDismissed: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Indeterminate pulse, then climb 0 → 100%, then flip to "installing".
|
||||||
|
const simulateDownload = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({
|
||||||
|
updateStatus: "downloading",
|
||||||
|
updateVersion: DEMO_UPDATE_VERSION,
|
||||||
|
updateProgress: null,
|
||||||
|
updateError: null,
|
||||||
|
updateDismissed: false,
|
||||||
|
});
|
||||||
|
let progress = 0;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
downloadTimer.current = window.setInterval(() => {
|
||||||
|
progress = Math.min(progress + 0.07, 1);
|
||||||
|
useGalleryStore.setState({ updateProgress: progress });
|
||||||
|
if (progress >= 1) {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({ updateStatus: "installing" });
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}, 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateInstalling = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({
|
||||||
|
updateStatus: "installing",
|
||||||
|
updateVersion: DEMO_UPDATE_VERSION,
|
||||||
|
updateProgress: 1,
|
||||||
|
updateDismissed: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateError = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({
|
||||||
|
updateStatus: "error",
|
||||||
|
updateError: "Could not reach the update server (connection timed out).",
|
||||||
|
updateDismissed: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUpToDate = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({ updateStatus: "upToDate", updateVersion: null, updateError: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetUpdate = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({
|
||||||
|
updateStatus: "idle",
|
||||||
|
updateVersion: null,
|
||||||
|
updateProgress: null,
|
||||||
|
updateError: null,
|
||||||
|
updateDismissed: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const injectBtn =
|
||||||
|
"rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25";
|
||||||
|
const neutralBtn =
|
||||||
|
"rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 left-4 z-[100] w-56 rounded-lg border border-amber-400/40 bg-amber-950/90 p-3 text-xs text-amber-100 shadow-xl backdrop-blur">
|
<div className="fixed bottom-4 left-4 z-[100] max-h-[calc(100vh-2rem)] w-56 overflow-y-auto rounded-lg border border-amber-400/40 bg-amber-950/90 p-3 text-xs text-amber-100 shadow-xl backdrop-blur">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span className="font-semibold uppercase tracking-wide">Demo · Ctrl+Shift+D</span>
|
<span className="font-semibold uppercase tracking-wide">Demo · Ctrl+Shift+D</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">Pipeline</p>
|
||||||
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
|
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
|
||||||
Inject a frozen worker-bar state, hide this panel, then screenshot.
|
Inject a frozen worker-bar state, hide this panel, then screenshot.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<button
|
<button className={injectBtn} onClick={injectBusy}>
|
||||||
className="rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25"
|
|
||||||
onClick={injectBusy}
|
|
||||||
>
|
|
||||||
Pipeline: busy (3 folders)
|
Pipeline: busy (3 folders)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className={injectBtn} onClick={injectSingleEmbedding}>
|
||||||
className="rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25"
|
|
||||||
onClick={injectSingleEmbedding}
|
|
||||||
>
|
|
||||||
Pipeline: embedding (1 folder)
|
Pipeline: embedding (1 folder)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className={neutralBtn} onClick={clear}>
|
||||||
className="rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10"
|
|
||||||
onClick={clear}
|
|
||||||
>
|
|
||||||
Clear injected state
|
Clear injected 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">Update flow</p>
|
||||||
|
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
|
||||||
|
Drives the real toast (bottom-right) + Settings → About row. Install is inert here.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<button className={injectBtn} onClick={updateAvailable}>
|
||||||
|
Update available
|
||||||
|
</button>
|
||||||
|
<button className={injectBtn} onClick={simulateDownload}>
|
||||||
|
Simulate download → install
|
||||||
|
</button>
|
||||||
|
<button className={injectBtn} onClick={updateInstalling}>
|
||||||
|
Installing
|
||||||
|
</button>
|
||||||
|
<button className={injectBtn} onClick={updateError}>
|
||||||
|
Check failed (error)
|
||||||
|
</button>
|
||||||
|
<button className={injectBtn} onClick={updateUpToDate}>
|
||||||
|
Up to date
|
||||||
|
</button>
|
||||||
|
<button className={neutralBtn} onClick={resetUpdate}>
|
||||||
|
Reset updater state
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,18 @@
|
|||||||
// <PhokusMark className="h-4 w-4 text-gray-300" />
|
// <PhokusMark className="h-4 w-4 text-gray-300" />
|
||||||
// The full app-icon tile (filled iris on a dark rounded square) lives in
|
// The full app-icon tile (filled iris on a dark rounded square) lives in
|
||||||
// branding/phokus-aperture.svg and feeds `pnpm tauri icon`.
|
// branding/phokus-aperture.svg and feeds `pnpm tauri icon`.
|
||||||
|
//
|
||||||
|
// Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point —
|
||||||
|
// used in the titlebar as the "update available" indicator.
|
||||||
const BLADE = "M0,-4.18 A10,10 0 0 1 6.43,-7.66";
|
const BLADE = "M0,-4.18 A10,10 0 0 1 6.43,-7.66";
|
||||||
|
|
||||||
export function PhokusMark({ className }: { className?: string }) {
|
export function PhokusMark({
|
||||||
|
className,
|
||||||
|
dotClassName,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
dotClassName?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="none" className={className}>
|
<svg viewBox="0 0 24 24" fill="none" className={className}>
|
||||||
<g
|
<g
|
||||||
@@ -24,6 +33,7 @@ export function PhokusMark({ className }: { className?: string }) {
|
|||||||
<path d={BLADE} transform="rotate(240)" />
|
<path d={BLADE} transform="rotate(240)" />
|
||||||
<path d={BLADE} transform="rotate(300)" />
|
<path d={BLADE} transform="rotate(300)" />
|
||||||
</g>
|
</g>
|
||||||
|
{dotClassName ? <circle cx="12" cy="12" r="2.6" stroke="none" className={dotClassName} /> : null}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ function CloseIcon() {
|
|||||||
export function TitleBar() {
|
export function TitleBar() {
|
||||||
const [isMaximized, setIsMaximized] = useState(false);
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
||||||
|
const updateStatus = useGalleryStore((state) => state.updateStatus);
|
||||||
|
const updateVersion = useGalleryStore((state) => state.updateVersion);
|
||||||
|
const installUpdate = useGalleryStore((state) => state.installUpdate);
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,6 +63,10 @@ export function TitleBar() {
|
|||||||
const handleMaximize = () => appWindow.toggleMaximize();
|
const handleMaximize = () => appWindow.toggleMaximize();
|
||||||
const handleClose = () => appWindow.close();
|
const handleClose = () => appWindow.close();
|
||||||
|
|
||||||
|
// An update is waiting for the user to act. Covers the "clicked Later" case too,
|
||||||
|
// since dismissing the toast doesn't change updateStatus.
|
||||||
|
const updatePending = updateStatus === "available";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// data-tauri-drag-region is the recommended Tauri approach for drag regions.
|
// data-tauri-drag-region is the recommended Tauri approach for drag regions.
|
||||||
// WebkitAppRegion is kept as a CSS fallback for compatibility.
|
// WebkitAppRegion is kept as a CSS fallback for compatibility.
|
||||||
@@ -68,11 +75,32 @@ export function TitleBar() {
|
|||||||
className="titlebar relative z-50 flex h-9 shrink-0 items-center bg-gray-950 select-none"
|
className="titlebar relative z-50 flex h-9 shrink-0 items-center bg-gray-950 select-none"
|
||||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
{/* App icon + name — left side */}
|
{/* App icon + name — left side. When an update is waiting, the iris lights
|
||||||
|
up its focal point and the chip becomes a button that re-opens the prompt. */}
|
||||||
<div className="flex items-center gap-2 pl-3 pr-4">
|
<div className="flex items-center gap-2 pl-3 pr-4">
|
||||||
|
{updatePending ? (
|
||||||
|
<div
|
||||||
|
className="group relative"
|
||||||
|
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => void installUpdate()}
|
||||||
|
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
|
||||||
|
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
|
||||||
|
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
|
||||||
|
</button>
|
||||||
|
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */}
|
||||||
|
<span className="pointer-events-none absolute left-0 top-full z-50 mt-1.5 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100">
|
||||||
|
Click to update — v{updateVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
|
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
|
||||||
<PhokusMark className="h-4 w-4" />
|
<PhokusMark className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<span className="text-[11px] font-semibold text-gray-400 tracking-wide">Phokus</span>
|
<span className="text-[11px] font-semibold text-gray-400 tracking-wide">Phokus</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user