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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user