import { acceptHMRUpdate, defineStore } from 'pinia' import { disposeClipartItems, isFileSystemAccessSupported, listClipart, loadStoredClipartFolders, pickClipartFolder, saveClipartFolders, type ClipartItem, } from '@/lib/clipartLibrary' interface State { /** 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 error: string | null supported: boolean initialized: boolean } const DRAWER_OPEN_KEY = 'animatr:clipartDrawerOpen' function readDrawerOpen(): boolean { try { return localStorage.getItem(DRAWER_OPEN_KEY) === '1' } catch { return false } } function writeDrawerOpen(open: boolean) { try { localStorage.setItem(DRAWER_OPEN_KEY, open ? '1' : '0') } catch { /* noop */ } } export const useClipartStore = defineStore('clipart', { state: (): State => ({ handles: [], activeHandleIndex: null, items: [], open: readDrawerOpen(), loading: false, error: null, 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 } if (this.handles.length > 0) { this.initialized = true return } const handles = await loadStoredClipartFolders() this.initialized = true 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 */ } }, /** 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 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' } }, /** 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.handles = [] this.activeHandleIndex = null this.items = [] this.error = null await saveClipartFolders([]) }, async refresh() { if (this.handles.length === 0) { this.disposeItems() this.items = [] return } this.loading = true this.error = null try { 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 { this.loading = false } }, disposeItems() { disposeClipartItems(this.items) }, setOpen(v: boolean) { this.open = v writeDrawerOpen(v) 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() } }, toggle() { this.setOpen(!this.open) }, }, }) // 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)) }