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:
1 parent
8aa91fc27b
commit
9fe86b62b9
9 files changed
+698
-142
No files matched your search
+139
-32
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user