0.14.0: multi-folder media drawer

- Clipart store holds an array of root folders instead of a single
  handle, plus an activeHandleIndex for the in-folder view. Getters:
  activeHandle, inOverview, hasAnyFolder, canGoBack, folderName.
- Actions: addFolder (appends a root), enterFolder, exitFolder,
  removeFolder. Single-folder installs auto-drill into the sole root
  and behave exactly like the pre-refactor drawer; adding a 2nd+
  folder pops back to the root overview.
- IDB persistence migrated from the 'clipartDir' single-handle key to
  a 'clipartDirs' array. Legacy records are migrated on first load.
  saveClipartFolders unwraps Pinia proxies (toRaw per element) so
  IDB's structured clone accepts the payload.
- ClipartItem union gained a 'folder' kind so root tiles render as
  first-class media items. listClipart emits nested directories too,
  but the drawer keeps drill-down one level deep for now.
- ClipartDrawer header: three equally-sized .header-btn chips —
  back (only when canGoBack), refresh (when viewing a folder), and a
  new `+` (when any folder is attached). No more big 'Choose folder'
  button — empty state is a centred 96px dashed + with a wrapped hint.
- Folder tiles carry a hover `×` to remove a root from the library.
- DocumentSettings media tab lists all configured folders with a per-
  folder remove action; primary button is now 'Add folder'.
- Pinia store now calls acceptHMRUpdate so Vite HMR actually swaps
  the store definition between reloads instead of leaving the first-
  load state shape behind.
- Tests: store actions (addFolder / enterFolder / exitFolder / remove
  / IDB round-trip), drawer UI (centred + empty state, overview with
  folder tiles + click-to-enter, back visibility, single-root hides
  back, header + button triggers addFolder). Tests isolated with
  try/finally so a failing store assertion can't leak error state
  into subsequent drawer tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 08:33:20 +02:00
1 parent 8aa91fc27b
commit 9fe86b62b9
9 files changed
+698 -142

No files matched your search

+139 -32
View File
@@ -1,17 +1,24 @@
import { defineStore } from 'pinia'
import { acceptHMRUpdate, defineStore } from 'pinia'
import {
clearStoredClipartFolder,
disposeClipartItems,
isFileSystemAccessSupported,
listClipart,
loadStoredClipartFolder,
loadStoredClipartFolders,
pickClipartFolder,
saveClipartFolders,
type ClipartItem,
} from '@/lib/clipartLibrary'
interface State {
handle: FileSystemDirectoryHandle | null
folderName: string | null
/** Root folders the user has added. Order matches display order. */
handles: FileSystemDirectoryHandle[]
/** Which root folder is being viewed. `null` means "root overview"
* (folder tiles, only used when `handles.length > 1`). With a single
* root the drawer auto-drills into it — callers should check
* `inOverview` rather than this field directly. */
activeHandleIndex: number | null
/** Current view's items — folder tiles in overview mode, files in
* in-folder mode. Consumers iterate this directly. */
items: ClipartItem[]
open: boolean
loading: boolean
@@ -40,8 +47,8 @@ function writeDrawerOpen(open: boolean) {
export const useClipartStore = defineStore('clipart', {
state: (): State => ({
handle: null,
folderName: null,
handles: [],
activeHandleIndex: null,
items: [],
open: readDrawerOpen(),
loading: false,
@@ -49,63 +56,150 @@ export const useClipartStore = defineStore('clipart', {
supported: isFileSystemAccessSupported(),
initialized: false,
}),
getters: {
/** Active root handle (either the explicitly-entered one or the
* sole-folder auto-drill). `null` when the drawer sits on the
* overview. */
activeHandle(state): FileSystemDirectoryHandle | null {
if (state.activeHandleIndex !== null) {
return state.handles[state.activeHandleIndex] ?? null
}
if (state.handles.length === 1) return state.handles[0]
return null
},
/** True when the drawer should render folder tiles instead of
* files. Only possible with 2+ roots. */
inOverview(state): boolean {
return state.handles.length > 1 && state.activeHandleIndex === null
},
/** Current folder's display name, or null in overview / empty. */
folderName(): string | null {
return this.activeHandle?.name ?? null
},
/** True when there's any folder configured at all. Drives the
* header-controls visibility in the drawer. */
hasAnyFolder(state): boolean {
return state.handles.length > 0
},
/** Back button is shown whenever the user can exit to the overview
* (requires an explicit active index AND multiple roots). */
canGoBack(state): boolean {
return state.activeHandleIndex !== null && state.handles.length > 1
},
},
actions: {
async initialize() {
if (!this.supported) {
this.initialized = true
return
}
// Safe to call multiple times; a second call will no-op once a handle
// has been loaded. If the previous attempt failed silently (e.g. IDB
// race on app start), a later call from Settings/drawer can retry.
if (this.handle) {
if (this.handles.length > 0) {
this.initialized = true
return
}
const handle = await loadStoredClipartFolder()
const handles = await loadStoredClipartFolders()
this.initialized = true
if (!handle) return
this.handle = handle
this.folderName = handle.name
if (handles.length === 0) return
this.handles = handles
try {
await this.refresh()
} catch {
/* permission may require a user gesture; items will populate on next open */
/* permission may require a user gesture; items will populate
on next open */
}
},
async pickFolder() {
/** Prompt the user for a folder and append it to the root list.
* Single-folder installs keep the auto-drill behaviour; adding a
* second root pops the drawer back to the overview so both roots
* are visible. */
async addFolder() {
this.error = null
try {
const handle = await pickClipartFolder()
if (!handle) return
this.setHandle(handle)
const wasEmpty = this.handles.length === 0
this.handles = [...this.handles, handle]
await saveClipartFolders(this.handles)
if (wasEmpty) {
// First folder — auto-drill via the getter. Nothing to do.
} else {
// Adding a 2nd+ folder: show the overview so the user sees
// every root at once.
this.activeHandleIndex = null
}
await this.refresh()
} catch (err) {
this.error = (err as Error).message ?? 'Failed to pick folder'
}
},
setHandle(handle: FileSystemDirectoryHandle) {
this.disposeItems()
this.handle = handle
this.folderName = handle.name
this.items = []
/** Legacy alias — early callers (DocumentSettings, tests) still
* invoke pickFolder. Keeps the same semantics as addFolder. */
async pickFolder() {
await this.addFolder()
},
enterFolder(index: number) {
if (index < 0 || index >= this.handles.length) return
this.activeHandleIndex = index
void this.refresh()
},
/** Pop to the overview. No-op with only one root (the drawer stays
* auto-drilled). */
exitFolder() {
if (this.handles.length <= 1) return
this.activeHandleIndex = null
void this.refresh()
},
async removeFolder(index: number) {
if (index < 0 || index >= this.handles.length) return
this.handles = this.handles.filter((_, i) => i !== index)
// Adjust the active pointer so it still points at the intended
// folder (or drops to overview when the active one was removed).
if (this.activeHandleIndex === index) {
this.activeHandleIndex = null
} else if (
this.activeHandleIndex !== null &&
this.activeHandleIndex > index
) {
this.activeHandleIndex -= 1
}
await saveClipartFolders(this.handles)
await this.refresh()
},
async clearFolder() {
this.disposeItems()
this.handle = null
this.folderName = null
this.handles = []
this.activeHandleIndex = null
this.items = []
this.error = null
await clearStoredClipartFolder()
await saveClipartFolders([])
},
async refresh() {
if (!this.handle) return
if (this.handles.length === 0) {
this.disposeItems()
this.items = []
return
}
this.loading = true
this.error = null
try {
const next = await listClipart(this.handle)
this.disposeItems()
this.items = next
const active = this.activeHandle
if (active) {
// In-folder view: list files; strip subdirectory entries so
// drilling stays one-level-deep (matches the pre-multi-root
// behaviour).
const next = await listClipart(active)
this.disposeItems()
this.items = next.filter((it) => it.kind !== 'folder')
} else {
// Overview: synthesize a folder tile per root. These items
// have no URLs to revoke, so disposeItems is cheap.
this.disposeItems()
this.items = this.handles.map((h) => ({
kind: 'folder' as const,
name: h.name,
handle: h,
}))
}
} catch (err) {
this.error = (err as Error).message ?? 'Failed to read folder'
} finally {
@@ -118,8 +212,14 @@ export const useClipartStore = defineStore('clipart', {
setOpen(v: boolean) {
this.open = v
writeDrawerOpen(v)
if (v && this.handle && this.items.length === 0 && !this.loading) {
// User gesture opens the drawer — good moment to (re-)request access
if (
v &&
this.handles.length > 0 &&
this.items.length === 0 &&
!this.loading
) {
// User gesture opens the drawer — good moment to (re-)request
// directory permission or recover from a cold-boot skip.
void this.refresh()
}
},
@@ -128,3 +228,10 @@ export const useClipartStore = defineStore('clipart', {
},
},
})
// Let Vite's HMR swap the store definition in place instead of leaving
// the initial (stale) state + getters behind — without this, edits to
// `State` or the getter surface only land after a full page reload.
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useClipartStore, import.meta.hot))
}