Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa3fe2062d | |||
| af1a443a64 | |||
| 302a3151ef | |||
| d7c3cbd683 |
+31
-13
@@ -41,6 +41,13 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|||||||
- **Choose your tagging model** — Settings -> AI Workspace now lets you pick
|
- **Choose your tagging model** — Settings -> AI Workspace now lets you pick
|
||||||
between the anime-focused WD tagger and JoyTag, which is better suited to photo
|
between the anime-focused WD tagger and JoyTag, which is better suited to photo
|
||||||
libraries and stronger on NSFW concepts (if that's your thing, we don't judge).
|
libraries and stronger on NSFW concepts (if that's your thing, we don't judge).
|
||||||
|
New users get the same choice during the welcome tour, and each model keeps its
|
||||||
|
own confidence threshold instead of sharing one.
|
||||||
|
- **Reset AI tags** — a new reset action in Settings -> AI Workspace and the Tag
|
||||||
|
manager wipes AI-generated tags for a folder or the whole library, cancelling
|
||||||
|
any tagging still in flight. Tag manager rows now show which tags came from the
|
||||||
|
AI, so you know what you are about to lose before you lose it. Manually-added
|
||||||
|
tags are never touched.
|
||||||
- **Related tags in Explore** — Hover over a tag in the Tag Cloud to see the tags
|
- **Related tags in Explore** — Hover over a tag in the Tag Cloud to see the tags
|
||||||
that most often appear with it, complete with connection lines and image counts.
|
that most often appear with it, complete with connection lines and image counts.
|
||||||
Handy for finding little clusters you did not know were there.
|
Handy for finding little clusters you did not know were there.
|
||||||
@@ -57,6 +64,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **Settings got a spring clean** — preferences are now organised into General,
|
||||||
|
Media, Updates & Setup, Storage, and AI Workspace pages, so update and
|
||||||
|
maintenance controls no longer hide at the bottom of unrelated sections.
|
||||||
- **Menus got their act together** — right-click menus (images, folders,
|
- **Menus got their act together** — right-click menus (images, folders,
|
||||||
albums, the theme switcher) and every dropdown (sort, folder scope, settings,
|
albums, the theme switcher) and every dropdown (sort, folder scope, settings,
|
||||||
sidebar) now share one style with one set of manners: they stay on screen
|
sidebar) now share one style with one set of manners: they stay on screen
|
||||||
@@ -95,40 +105,48 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|||||||
- **Explore no longer flashes the last folder** — switching folders now clears
|
- **Explore no longer flashes the last folder** — switching folders now clears
|
||||||
the old clusters/tags and shows a loading state while the new folder catches
|
the old clusters/tags and shows a loading state while the new folder catches
|
||||||
up.
|
up.
|
||||||
- **Ratings keep your search order** — rating or favoriting an image no longer
|
- **Ratings keep your search order** — if you ever rated an image mid-search and
|
||||||
reshuffles similar-image, region, semantic, tag, or album results.
|
watched your results reshuffle themselves, that's over. Similar-image, region,
|
||||||
|
semantic, tag, and album results now stay put.
|
||||||
- **Update progress comes back when you need it** — if you dismiss the update
|
- **Update progress comes back when you need it** — if you dismiss the update
|
||||||
prompt and later start the update from the title bar or Settings, the progress
|
prompt and later start the update from the title bar or Settings, the progress
|
||||||
toast now reappears instead of hiding away in Settings.
|
toast now reappears instead of hiding away in Settings.
|
||||||
- **Subtle Light cleanup** — fixed dark or hard-to-read surfaces, hover states,
|
- **Subtle Light cleanup** — fixed dark or hard-to-read surfaces, hover states,
|
||||||
dialogs, updater buttons, onboarding controls, and green action buttons in the
|
dialogs, updater buttons, onboarding controls, and green action buttons in the
|
||||||
light theme.
|
light theme.
|
||||||
- **No more self-indexing loops** — if you add a broad folder like your user
|
- **No more self-indexing loops** — adding a broad folder like your whole user
|
||||||
profile, Phokus now skips its own app-data directory instead of indexing its
|
profile used to send Phokus off indexing its own thumbnail cache, generating
|
||||||
thumbnail cache forever.
|
thumbnails of thumbnails until the end of time. It now skips its own app-data
|
||||||
|
directory.
|
||||||
- **Background tasks show the active work first** — when one folder is paused and
|
- **Background tasks show the active work first** — when one folder is paused and
|
||||||
another is processing, the active folder gets the main spot in the background
|
another is processing, the active folder gets the main spot in the background
|
||||||
tasks bar.
|
tasks bar.
|
||||||
- **First launch fits smaller screens** — fresh installs now clamp the window to
|
- **First launch fits smaller screens** — on 1366x768-style displays, fresh
|
||||||
the usable monitor area, so 1366x768-style displays do not lose part of the app
|
installs used to open with part of the app tucked below the taskbar. The window
|
||||||
below the taskbar.
|
now clamps itself to the usable monitor area.
|
||||||
- **Explore is clearer in Subtle Light** — cluster captions, buttons, cloud
|
- **Explore is clearer in Subtle Light** — cluster captions, buttons, cloud
|
||||||
words, hover glows, and the new connection lines now use stronger light-theme
|
words, hover glows, and the new connection lines now use stronger light-theme
|
||||||
colours.
|
colours.
|
||||||
- **Explore got a few sharp edges sanded down** — Cluster Cloud uses the in-app
|
- **Explore got a few sharp edges sanded down** — Cluster Cloud uses the in-app
|
||||||
tooltip, singular counts now say "1 image", and the folder-scope dropdown no
|
tooltip, singular counts now say "1 image", and the folder-scope dropdown no
|
||||||
longer hides behind cluster cards.
|
longer hides behind cluster cards.
|
||||||
- **AI tagging stays responsive** — GPU tagging now works in smaller bursts with
|
- **AI tagging stays responsive** — starting a big tagging job used to turn the
|
||||||
brief pauses between them, so the UI keeps moving and the first batch starts
|
rest of the app into a slideshow. GPU tagging now works in smaller bursts with
|
||||||
|
brief pauses between them, so the UI keeps moving and the first results land
|
||||||
sooner.
|
sooner.
|
||||||
|
- **Lightbox AI tags wake up on time** — the lightbox's AI tags action no longer
|
||||||
|
stays disabled until you happen to open Settings, and it switches on as soon as
|
||||||
|
a model download finishes.
|
||||||
- **Noisy AI tags get cleaned up** — generic low-signal tags from WD and JoyTag
|
- **Noisy AI tags get cleaned up** — generic low-signal tags from WD and JoyTag
|
||||||
are filtered before they are saved, and matching older generated tags are
|
are filtered before they are saved, and matching older generated tags are
|
||||||
cleaned up on startup. Your manually-added tags are left alone.
|
cleaned up on startup. Your manually-added tags are left alone.
|
||||||
- **Selected Folders starts empty** — choosing "Selected Folders" for AI tagging
|
- **Selected Folders starts empty** — choosing "Selected Folders" for AI tagging
|
||||||
no longer pre-selects the first folder. You decide exactly what gets queued.
|
no longer pre-selects the first folder. You decide exactly what gets queued.
|
||||||
- **A couple of tiny UI papercuts are gone** — the zoom buttons now show the
|
- **A handful of tiny UI papercuts are gone** — the zoom buttons now show the
|
||||||
right tile size when you hover them, and the folder picker no longer adds an
|
right tile size when you hover them, the folder picker no longer adds an odd
|
||||||
odd trailing slash to Windows drive breadcrumbs.
|
trailing slash to Windows drive breadcrumbs, the search field's clear button
|
||||||
|
no longer sits a few pixels too high, and menu labels no longer highlight like
|
||||||
|
text when you drag across them.
|
||||||
|
|
||||||
## [0.1.1] — 2026-06-23
|
## [0.1.1] — 2026-06-23
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -40,7 +40,9 @@ UI Lab reads `?scenario=` from the URL. If no scenario is provided, it uses
|
|||||||
| URL | Purpose |
|
| URL | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/?scenario=rich` | Default realistic library with folders, albums, ratings, favorites, tags, images, and videos |
|
| `/?scenario=rich` | Default realistic library with folders, albums, ratings, favorites, tags, images, and videos |
|
||||||
| `/?scenario=empty` | First-run state with no folders or media |
|
| `/?scenario=empty` | Empty library with no folders or media (onboarding already completed) |
|
||||||
|
| `/?scenario=new-user` | True first run: onboarding tour open, empty library, no tagger model downloaded |
|
||||||
|
| `/?scenario=just-updated` | Rich library that has just been updated — the "What's new" toast fires on launch |
|
||||||
| `/?scenario=busy` | Background workers with pending thumbnail, metadata, embedding, caption, and tagging jobs |
|
| `/?scenario=busy` | Background workers with pending thumbnail, metadata, embedding, caption, and tagging jobs |
|
||||||
| `/?scenario=duplicates` | Duplicate Finder opened with duplicate groups already available |
|
| `/?scenario=duplicates` | Duplicate Finder opened with duplicate groups already available |
|
||||||
| `/?scenario=album` | Gallery opened directly into an album |
|
| `/?scenario=album` | Gallery opened directly into an album |
|
||||||
@@ -55,6 +57,23 @@ http://127.0.0.1:1422/?scenario=errors
|
|||||||
http://127.0.0.1:1422/?scenario=huge
|
http://127.0.0.1:1422/?scenario=huge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Changelog Previews
|
||||||
|
|
||||||
|
The What's New modal adapts its layout to release size (compact single column
|
||||||
|
for small releases, a two-pane section rail for large ones). UI Lab reads
|
||||||
|
`?changelog=` to override which entry the modal shows:
|
||||||
|
|
||||||
|
| URL | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/?changelog=unreleased` | The in-progress `[Unreleased]` notes — large release, rail layout |
|
||||||
|
| `/?changelog=small` | Synthetic hotfix-sized entry — compact single-column layout |
|
||||||
|
| `/?changelog=0.1.1` | Any specific released version |
|
||||||
|
|
||||||
|
Open the modal via the demo panel: `Ctrl+Shift+D` → Open "What's new" modal.
|
||||||
|
|
||||||
|
Combine with the scenario above to walk the whole post-update greeting for the
|
||||||
|
next release: `/?scenario=just-updated&changelog=unreleased`.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
`src/main.tsx` bootstraps the app asynchronously. In `ui` mode it imports
|
`src/main.tsx` bootstraps the app asynchronously. In `ui` mode it imports
|
||||||
|
|||||||
@@ -107,7 +107,65 @@ function parseChangelog(raw: string): ChangelogEntry[] {
|
|||||||
|
|
||||||
const ENTRIES = parseChangelog(changelogRaw)
|
const ENTRIES = parseChangelog(changelogRaw)
|
||||||
|
|
||||||
|
// Synthetic small release for UI Lab (`?changelog=small`). The What's New modal
|
||||||
|
// switches layout by release size, and every real entry in CHANGELOG.md is
|
||||||
|
// large — so the compact single-column layout can't be exercised from real
|
||||||
|
// data. This stays below the modal's rail threshold on purpose.
|
||||||
|
const SMALL_PREVIEW_ENTRY: ChangelogEntry = {
|
||||||
|
version: '0.0.0-preview',
|
||||||
|
date: '2026-01-01',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Added',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
lead: 'Sample setting',
|
||||||
|
body: 'a small toggle that exists purely so this preview has an Added section.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Changed',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
lead: 'Snappier previews',
|
||||||
|
body: 'the preview fixture now loads instantly, because it is made up.',
|
||||||
|
},
|
||||||
|
{ lead: null, body: 'A plain full-sentence bullet without a bold lead, for coverage.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fixed',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
lead: 'Hotfix-sized fix',
|
||||||
|
body: 'a believable one-liner about a bug that never shipped.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lead: 'Another small fix',
|
||||||
|
body: 'keeps the total item count comfortably under the rail threshold.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI Lab affordance (browser-only `ui` mode): `?changelog=` previews an entry
|
||||||
|
// other than the running version's, mirroring the `?scenario=` pattern.
|
||||||
|
// ?changelog=unreleased — the in-progress [Unreleased] notes (large release)
|
||||||
|
// ?changelog=small — synthetic hotfix-sized entry (compact layout)
|
||||||
|
// ?changelog=0.1.1 — any specific released version
|
||||||
|
function previewOverride(): ChangelogEntry | null {
|
||||||
|
if (import.meta.env.MODE !== 'ui') return null
|
||||||
|
const preview = new URLSearchParams(window.location.search).get('changelog')
|
||||||
|
if (!preview) return null
|
||||||
|
if (preview === 'small') return SMALL_PREVIEW_ENTRY
|
||||||
|
return ENTRIES.find((e) => e.version.toLowerCase() === preview.toLowerCase()) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
|
export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
|
||||||
|
const override = previewOverride()
|
||||||
|
if (override) return override
|
||||||
if (!version) return null
|
if (!version) return null
|
||||||
// Strip leading "v" and any build suffix (e.g. "-ui", "-dev", "-beta.1") so
|
// Strip leading "v" and any build suffix (e.g. "-ui", "-dev", "-beta.1") so
|
||||||
// dev/UI-lab builds still resolve to the correct changelog entry.
|
// dev/UI-lab builds still resolve to the correct changelog entry.
|
||||||
|
|||||||
@@ -2,10 +2,30 @@ import { useEffect, useState, type ReactNode } from 'react'
|
|||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { useGalleryStore } from '../store'
|
import { useGalleryStore } from '../store'
|
||||||
import { getChangelogForVersion, type ChangelogSection } from '../changelog'
|
import {
|
||||||
|
getChangelogForVersion,
|
||||||
|
type ChangelogEntry,
|
||||||
|
type ChangelogItem,
|
||||||
|
type ChangelogSection,
|
||||||
|
} from '../changelog'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import { ChevronRightIcon, CloseIcon } from './icons'
|
import { ChevronRightIcon, CloseIcon } from './icons'
|
||||||
|
|
||||||
|
// The modal adapts its layout to the size of the release: small releases keep
|
||||||
|
// the compact single-column list, while big ones switch to a two-pane layout
|
||||||
|
// with a section nav rail so no one pane is a marathon scroll. Preview both in
|
||||||
|
// UI Lab via `?changelog=unreleased` + the demo panel's "Open What's new modal".
|
||||||
|
type WhatsNewLayout = 'single' | 'rail'
|
||||||
|
|
||||||
|
// Above this many total bullets, the single column gets unpleasantly long.
|
||||||
|
const RAIL_THRESHOLD = 20
|
||||||
|
|
||||||
|
function chooseLayout(entry: ChangelogEntry | null): WhatsNewLayout {
|
||||||
|
if (!entry || entry.sections.length < 2) return 'single'
|
||||||
|
const total = entry.sections.reduce((sum, section) => sum + section.items.length, 0)
|
||||||
|
return total > RAIL_THRESHOLD ? 'rail' : 'single'
|
||||||
|
}
|
||||||
|
|
||||||
// Shown in the tooltip so the user can see the link's destination before
|
// Shown in the tooltip so the user can see the link's destination before
|
||||||
// clicking. The actual navigation is handled by the open_changelog_url command.
|
// clicking. The actual navigation is handled by the open_changelog_url command.
|
||||||
const CHANGELOG_URL = 'https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md'
|
const CHANGELOG_URL = 'https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md'
|
||||||
@@ -25,6 +45,10 @@ const SECTION_STYLES: Record<string, { label: string; dot: string }> = {
|
|||||||
|
|
||||||
const NEUTRAL_STYLE = { label: 'text-gray-300', dot: 'bg-gray-400/70' }
|
const NEUTRAL_STYLE = { label: 'text-gray-300', dot: 'bg-gray-400/70' }
|
||||||
|
|
||||||
|
function sectionStyle(title: string) {
|
||||||
|
return SECTION_STYLES[title] ?? NEUTRAL_STYLE
|
||||||
|
}
|
||||||
|
|
||||||
// Render the small amount of inline markdown the changelog uses: **bold** and
|
// Render the small amount of inline markdown the changelog uses: **bold** and
|
||||||
// `code`. Anything else passes through as plain text.
|
// `code`. Anything else passes through as plain text.
|
||||||
function renderInline(text: string): ReactNode[] {
|
function renderInline(text: string): ReactNode[] {
|
||||||
@@ -58,8 +82,44 @@ function renderInline(text: string): ReactNode[] {
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ section }: { section: ChangelogSection }) {
|
function Item({ item, dot }: { item: ChangelogItem; dot: string }) {
|
||||||
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE
|
return (
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className={`mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full ${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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemList({
|
||||||
|
items,
|
||||||
|
dot,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
items: ChangelogItem[]
|
||||||
|
dot: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul className={`space-y-2.5 ${className}`}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Item key={index} item={item} dot={dot} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleSection({ section }: { section: ChangelogSection }) {
|
||||||
|
const style = sectionStyle(section.title)
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,23 +148,7 @@ function Section({ section }: { section: ChangelogSection }) {
|
|||||||
transition={{ duration: 0.18 }}
|
transition={{ duration: 0.18 }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<ul className="mt-2.5 space-y-2.5 pl-5">
|
<ItemList items={section.items} dot={style.dot} className="mt-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>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -112,12 +156,61 @@ function Section({ section }: { section: ChangelogSection }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 'rail' layout — a section nav rail on the left, the selected section's items
|
||||||
|
// on the right.
|
||||||
|
function RailSections({ sections }: { sections: ChangelogSection[] }) {
|
||||||
|
const [active, setActive] = useState(0)
|
||||||
|
const section = sections[Math.min(active, sections.length - 1)]
|
||||||
|
const style = sectionStyle(section.title)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<nav className="flex w-44 shrink-0 flex-col gap-1 border-r border-white/[0.07] px-3 py-4">
|
||||||
|
{sections.map((s, index) => {
|
||||||
|
const navStyle = sectionStyle(s.title)
|
||||||
|
const isActive = index === active
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.title}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(index)}
|
||||||
|
className={`flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-[12px] font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-white/[0.07] text-white'
|
||||||
|
: 'text-gray-500 hover:bg-white/[0.04] hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${navStyle.dot}`} />
|
||||||
|
<span className="flex-1">{s.title}</span>
|
||||||
|
<span className="text-[11px] font-medium text-gray-600">{s.items.length}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<p
|
||||||
|
className={`mb-3 text-[11px] font-semibold tracking-[0.1em] uppercase ${style.label}`}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</p>
|
||||||
|
<ItemList items={section.items} dot={style.dot} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODAL_WIDTHS: Record<WhatsNewLayout, string> = {
|
||||||
|
single: 'w-[min(88vw,560px)]',
|
||||||
|
rail: 'w-[min(90vw,780px)]',
|
||||||
|
}
|
||||||
|
|
||||||
export function WhatsNewModal() {
|
export function WhatsNewModal() {
|
||||||
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
|
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
|
||||||
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew)
|
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew)
|
||||||
const appVersion = useGalleryStore((s) => s.appVersion)
|
const appVersion = useGalleryStore((s) => s.appVersion)
|
||||||
|
|
||||||
const entry = getChangelogForVersion(appVersion)
|
const entry = getChangelogForVersion(appVersion)
|
||||||
|
const layout = chooseLayout(entry)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!whatsNewOpen) return
|
if (!whatsNewOpen) return
|
||||||
@@ -128,6 +221,8 @@ export function WhatsNewModal() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [whatsNewOpen, closeWhatsNew])
|
}, [whatsNewOpen, closeWhatsNew])
|
||||||
|
|
||||||
|
const hasSections = entry !== null && entry.sections.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{whatsNewOpen ? (
|
{whatsNewOpen ? (
|
||||||
@@ -140,7 +235,7 @@ export function WhatsNewModal() {
|
|||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<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"
|
className={`relative flex max-h-[min(82vh,760px)] ${MODAL_WIDTHS[layout]} flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60`}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
initial={{ opacity: 0, scale: 0.97, y: 8 }}
|
initial={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
@@ -169,16 +264,22 @@ export function WhatsNewModal() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasSections && layout === 'rail' ? (
|
||||||
|
<RailSections sections={entry.sections} />
|
||||||
|
) : (
|
||||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
|
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
|
||||||
{entry && entry.sections.length > 0 ? (
|
{hasSections ? (
|
||||||
entry.sections.map((section) => <Section key={section.title} section={section} />)
|
entry.sections.map((section) => (
|
||||||
|
<CollapsibleSection key={section.title} section={section} />
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Release notes for this version aren't available in-app. See the full changelog on
|
Release notes for this version aren't available in-app. See the full changelog
|
||||||
GitHub.
|
on GitHub.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
|
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
|
||||||
<Tooltip label={CHANGELOG_URL} side="top" align="start">
|
<Tooltip label={CHANGELOG_URL} side="top" align="start">
|
||||||
|
|||||||
+16
-7
@@ -11,14 +11,18 @@ import type {
|
|||||||
TaggerRuntimeProbe,
|
TaggerRuntimeProbe,
|
||||||
} from '../store'
|
} from '../store'
|
||||||
import { compareImages, createMockDb, mockExif } from './mockFixtures'
|
import { compareImages, createMockDb, mockExif } from './mockFixtures'
|
||||||
import { getMockScenario } from './mockScenarios'
|
import { getMockScenario, isEmptyLibraryScenario } from './mockScenarios'
|
||||||
|
|
||||||
const db = createMockDb(getMockScenario())
|
const db = createMockDb(getMockScenario())
|
||||||
let nextFolderId = 100
|
let nextFolderId = 100
|
||||||
let nextAlbumId = 100
|
let nextAlbumId = 100
|
||||||
let nextTagId = 10_000
|
let nextTagId = 10_000
|
||||||
|
// A true first run ('new-user') has no tagger model downloaded yet, so the
|
||||||
|
// onboarding AI step offers the real model-choice + download flow.
|
||||||
|
const taggerStartsUnready = (scenario: typeof db.scenario) =>
|
||||||
|
scenario === 'unready' || scenario === 'joytag-unready' || scenario === 'new-user'
|
||||||
let mockTaggerModel: TaggerModel = db.scenario === 'joytag-unready' ? 'joytag' : 'wd'
|
let mockTaggerModel: TaggerModel = db.scenario === 'joytag-unready' ? 'joytag' : 'wd'
|
||||||
let mockTaggerReady = db.scenario !== 'unready' && db.scenario !== 'joytag-unready'
|
let mockTaggerReady = !taggerStartsUnready(db.scenario)
|
||||||
const mockTaggerThresholds: Record<TaggerModel, number> = {
|
const mockTaggerThresholds: Record<TaggerModel, number> = {
|
||||||
wd: 0.35,
|
wd: 0.35,
|
||||||
joytag: 0.4,
|
joytag: 0.4,
|
||||||
@@ -405,9 +409,9 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
case 'find_similar_by_region':
|
case 'find_similar_by_region':
|
||||||
return similarImages(payload)
|
return similarImages(payload)
|
||||||
case 'get_visual_clusters':
|
case 'get_visual_clusters':
|
||||||
return db.scenario === 'empty' ? [] : db.visualClusters
|
return isEmptyLibraryScenario(db.scenario) ? [] : db.visualClusters
|
||||||
case 'get_explore_tags':
|
case 'get_explore_tags':
|
||||||
return db.scenario === 'empty'
|
return isEmptyLibraryScenario(db.scenario)
|
||||||
? []
|
? []
|
||||||
: db.scenario === 'extreme'
|
: db.scenario === 'extreme'
|
||||||
? db.exploreTags
|
? db.exploreTags
|
||||||
@@ -607,7 +611,7 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
return mockTaggerModel
|
return mockTaggerModel
|
||||||
case 'set_tagger_model':
|
case 'set_tagger_model':
|
||||||
mockTaggerModel = p.model ?? 'wd'
|
mockTaggerModel = p.model ?? 'wd'
|
||||||
mockTaggerReady = db.scenario !== 'unready' && db.scenario !== 'joytag-unready'
|
mockTaggerReady = !taggerStartsUnready(db.scenario)
|
||||||
return mockTaggerModel
|
return mockTaggerModel
|
||||||
case 'prepare_tagger_model':
|
case 'prepare_tagger_model':
|
||||||
mockTaggerReady = true
|
mockTaggerReady = true
|
||||||
@@ -644,8 +648,11 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
return null
|
return null
|
||||||
case 'get_notifications_paused':
|
case 'get_notifications_paused':
|
||||||
case 'get_worker_pauses_persist':
|
case 'get_worker_pauses_persist':
|
||||||
|
return !isEmptyLibraryScenario(db.scenario)
|
||||||
|
// Only 'new-user' greets with the onboarding tour; 'empty' is a bare
|
||||||
|
// library that has already been through it.
|
||||||
case 'get_onboarding_completed':
|
case 'get_onboarding_completed':
|
||||||
return db.scenario !== 'empty'
|
return db.scenario !== 'new-user'
|
||||||
case 'set_onboarding_completed':
|
case 'set_onboarding_completed':
|
||||||
case 'set_last_seen_version':
|
case 'set_last_seen_version':
|
||||||
return null
|
return null
|
||||||
@@ -659,8 +666,10 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
}))
|
}))
|
||||||
case 'get_build_variant':
|
case 'get_build_variant':
|
||||||
return 'cpu'
|
return 'cpu'
|
||||||
|
// 'just-updated' reports an older last-seen version, so the real
|
||||||
|
// initWhatsNew logic fires the post-update "What's new" toast on launch.
|
||||||
case 'get_last_seen_version':
|
case 'get_last_seen_version':
|
||||||
return '0.1.1-ui'
|
return db.scenario === 'just-updated' ? '0.1.0' : '0.1.1-ui'
|
||||||
case 'get_ffmpeg_status':
|
case 'get_ffmpeg_status':
|
||||||
return { installed: true, downloading: false, failed: false }
|
return { installed: true, downloading: false, failed: false }
|
||||||
case 'retry_ffmpeg_download':
|
case 'retry_ffmpeg_download':
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
SortOrder,
|
SortOrder,
|
||||||
VisualClusterEntry,
|
VisualClusterEntry,
|
||||||
} from '../store'
|
} from '../store'
|
||||||
import type { MockScenario } from './mockScenarios'
|
import { isEmptyLibraryScenario, type MockScenario } from './mockScenarios'
|
||||||
import { fixtureMediaPath } from './mockMedia'
|
import { fixtureMediaPath } from './mockMedia'
|
||||||
|
|
||||||
export interface MockDb {
|
export interface MockDb {
|
||||||
@@ -210,7 +210,7 @@ function makeImage(index: number, folderId: number, scenario: MockScenario): Ima
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeFolders(scenario: MockScenario): Folder[] {
|
function makeFolders(scenario: MockScenario): Folder[] {
|
||||||
if (scenario === 'empty') return []
|
if (isEmptyLibraryScenario(scenario)) return []
|
||||||
const names = scenario === 'extreme' ? extremeFolderNames : folderNames
|
const names = scenario === 'extreme' ? extremeFolderNames : folderNames
|
||||||
return names.map((name, index) => ({
|
return names.map((name, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
@@ -224,7 +224,7 @@ function makeFolders(scenario: MockScenario): Folder[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
|
function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
|
||||||
if (scenario === 'empty') return []
|
if (isEmptyLibraryScenario(scenario)) return []
|
||||||
const count =
|
const count =
|
||||||
scenario === 'extreme' ? 1_440 : scenario === 'huge' ? 720 : scenario === 'errors' ? 54 : 72
|
scenario === 'extreme' ? 1_440 : scenario === 'huge' ? 720 : scenario === 'errors' ? 54 : 72
|
||||||
const images = Array.from({ length: count }, (_, index) =>
|
const images = Array.from({ length: count }, (_, index) =>
|
||||||
@@ -268,7 +268,7 @@ function makeAlbums(
|
|||||||
images: ImageRecord[],
|
images: ImageRecord[],
|
||||||
scenario: MockScenario
|
scenario: MockScenario
|
||||||
): { albums: Album[]; albumImageIds: Record<number, number[]> } {
|
): { albums: Album[]; albumImageIds: Record<number, number[]> } {
|
||||||
if (scenario === 'empty') return { albums: [], albumImageIds: {} }
|
if (isEmptyLibraryScenario(scenario)) return { albums: [], albumImageIds: {} }
|
||||||
const selects = images
|
const selects = images
|
||||||
.filter((image) => image.rating >= 4)
|
.filter((image) => image.rating >= 4)
|
||||||
.slice(0, 36)
|
.slice(0, 36)
|
||||||
@@ -401,7 +401,7 @@ function makeExploreTags(images: ImageRecord[], scenario: MockScenario): Explore
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] {
|
function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] {
|
||||||
if (scenario === 'empty') return []
|
if (isEmptyLibraryScenario(scenario)) return []
|
||||||
const candidates = images.filter((image) => image.media_kind === 'image')
|
const candidates = images.filter((image) => image.media_kind === 'image')
|
||||||
const groups = [0, 1, 2, 3]
|
const groups = [0, 1, 2, 3]
|
||||||
.map((groupIndex) => ({
|
.map((groupIndex) => ({
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type MockScenario =
|
|||||||
| 'extreme'
|
| 'extreme'
|
||||||
| 'unready'
|
| 'unready'
|
||||||
| 'joytag-unready'
|
| 'joytag-unready'
|
||||||
|
| 'new-user'
|
||||||
|
| 'just-updated'
|
||||||
|
|
||||||
const SCENARIOS = new Set<MockScenario>([
|
const SCENARIOS = new Set<MockScenario>([
|
||||||
'rich',
|
'rich',
|
||||||
@@ -21,8 +23,17 @@ const SCENARIOS = new Set<MockScenario>([
|
|||||||
'extreme',
|
'extreme',
|
||||||
'unready',
|
'unready',
|
||||||
'joytag-unready',
|
'joytag-unready',
|
||||||
|
'new-user',
|
||||||
|
'just-updated',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Scenarios that start with no folders or media. 'empty' is a bare library
|
||||||
|
// with onboarding already behind it; 'new-user' is the true first run and
|
||||||
|
// additionally opens the onboarding tour with no tagger model downloaded.
|
||||||
|
export function isEmptyLibraryScenario(scenario: MockScenario): boolean {
|
||||||
|
return scenario === 'empty' || scenario === 'new-user'
|
||||||
|
}
|
||||||
|
|
||||||
export function getMockScenario(): MockScenario {
|
export function getMockScenario(): MockScenario {
|
||||||
if (typeof window === 'undefined') return 'rich'
|
if (typeof window === 'undefined') return 'rich'
|
||||||
const value = new URLSearchParams(window.location.search).get('scenario')
|
const value = new URLSearchParams(window.location.search).get('scenario')
|
||||||
|
|||||||
Reference in New Issue
Block a user