/** * Prompt-template library overlay: a multi-source, searchable, category- * filtered gallery. Each registered source (TEMPLATE_SOURCES) renders as its * own tab with an independent list, refresh state, and image pool; case lists * are served by the host (bundled snapshot, optionally refreshed online or * auto-synced in the background) and reference images load lazily through the * host's caching proxy, so browsing progressively mirrors the gallery onto the * local disk. Templates can be starred; favorites persist host-side as full * case snapshots and are reachable through a top-level tab grouped by source. * Picking a template hands its prompt back and closes the library. */ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { BookOpen, Maximize2, Minimize2 } from 'lucide-react' import { createPortal } from 'react-dom' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { ImageGenApi } from './api.ts' import { errorMessage, tt } from './helpers.ts' import { TEMPLATE_SOURCES, TEMPLATES_API, type TemplateCase, type TemplateFavorite, type TemplateListResult } from '../protocol.ts' import { DRAWER_WIDTH_KEY, readDrawerWidth, readRecentTemplates, rememberTemplate, type TemplateApplyMode, type TemplateEntry } from './template-library-state.ts' import css from './templates.module.css' /** Detail-view selection keeps its source so proxy URLs and stars stay scoped. */ interface SelectedTemplate extends TemplateEntry {} /** Concurrent image downloads while caching the whole gallery offline. */ const CACHE_ALL_CONCURRENCY = 4 /** Stable favorites key of one case within a source. */ function favoriteKeyOf(sourceId: string, item: TemplateCase): string { return `${sourceId}:${item.id}` } /** Same-origin URL of one case's reference image (host caching proxy). */ function imageUrlOf(sourceId: string, item: TemplateCase): string { return `${TEMPLATES_API.image}/${encodeURIComponent(sourceId)}/${encodeURIComponent(item.image)}` } /** A card thumbnail that falls back to a placeholder when the proxy 404s. */ function TemplateThumb(props: { sourceId: string; item: TemplateCase }) { const [failed, setFailed] = useState(false) if (props.item.image === '' || failed) { return ( ) } return ( { setFailed(true) }} /> ) } /** Card-corner star toggle; the click must not open the detail view. Rendered * as a span (a button cannot nest inside the card button). */ function FavoriteStar(props: { active: boolean; title: string; onToggle: () => void }) { return ( { event.stopPropagation(); props.onToggle() }} onKeyDown={(event) => { if (event.key !== 'Enter' && event.key !== ' ') return event.stopPropagation() event.preventDefault() props.onToggle() }} > ) } /** Shared floating entry, positioned inside the active creation workspace. */ export function TemplateLibraryButton(props: { onClick: () => void; expanded: boolean }): React.JSX.Element { return event.stopPropagation()} onClick={props.onClick}> {tt('templates.shortLabel')} } /** The shared wide drawer. Existing callers keep the same prompt callback. */ export function TemplateLibrary(props: { api: ImageGenApi /** Hand a picked prompt back to the studio form and close the library. */ onUse: (prompt: string, mode?: TemplateApplyMode) => void anchor?: HTMLElement | null hasExistingPrompt?: boolean appendOnly?: boolean onClose: () => void }) { const { api, onUse, onClose } = props const shellRef = useRef(null) const detailRef = useRef(null) const [bounds, setBounds] = useState({}) const [widthRatio, setWidthRatio] = useState(readDrawerWidth) const [fullscreen, setFullscreen] = useState(false) const [resizing, setResizing] = useState(false) const overlayRef = useRef(null) const requestedSources = useRef(new Set()) const previousFocus = useRef(document.activeElement as HTMLElement | null) const [recents, setRecents] = useState(readRecentTemplates) const [recentView, setRecentView] = useState(false) const [searchAll, setSearchAll] = useState(false) const [activeSource, setActiveSource] = useState(TEMPLATE_SOURCES[0]!.id) const [lists, setLists] = useState>({}) const [loadErrors, setLoadErrors] = useState>({}) const [favorites, setFavorites] = useState([]) const [query, setQuery] = useState('') const [category, setCategory] = useState('') const [favoritesView, setFavoritesView] = useState(false) const [selected, setSelected] = useState(null) const [copied, setCopied] = useState(false) const [refreshing, setRefreshing] = useState(false) const [notice, setNotice] = useState(null) const [cacheAll, setCacheAll] = useState<{ running: boolean; done: number; total: number }>({ running: false, done: 0, total: 0 }) const searchRef = useRef(null) const list = lists[activeSource] const specialView = favoritesView || recentView const sourceIds = searchAll ? TEMPLATE_SOURCES.map(source => source.id) : [activeSource] const loadError = specialView ? null : sourceIds.map(id => loadErrors[id]).filter(Boolean).join(' · ') || null const loading = !specialView && sourceIds.some(id => lists[id] === undefined && !loadErrors[id]) /** Fetch one source's list into the per-source cache. */ const loadSource = useCallback((sourceId: string, retry = false): void => { if (!retry && requestedSources.current.has(sourceId)) return requestedSources.current.add(sourceId) setLoadErrors(current => ({ ...current, [sourceId]: '' })) api.templatesList(sourceId) .then(result => { setLists(current => ({ ...current, [sourceId]: result })) setLoadErrors(current => ({ ...current, [sourceId]: '' })) }) .catch(caught => { setLoadErrors(current => ({ ...current, [sourceId]: errorMessage(caught) })) }) }, [api]) // Load the first source plus the favorites on open; focus the search box. useEffect(() => { loadSource(TEMPLATE_SOURCES[0]!.id) api.favoritesList().then(setFavorites).catch(() => { /* favorites stay empty; toggling retries */ }) searchRef.current?.focus() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const switchSource = (sourceId: string): void => { if (sourceId === activeSource && !specialView && !searchAll) return setActiveSource(sourceId) setCategory('') setFavoritesView(false) setRecentView(false) setSearchAll(false) setSelected(null) setNotice(null) if (lists[sourceId] === undefined) loadSource(sourceId) } // Bound the portal to the active work area rather than covering the host shell. useEffect(() => { const anchor = props.anchor if (!anchor) return const measure = (): void => { const rect = anchor.getBoundingClientRect() if (rect.width > 0 && rect.height > 0) setBounds({ left: rect.left, top: rect.top, width: rect.width, height: rect.height, right: 'auto', bottom: 'auto' }) } measure() const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure) observer?.observe(anchor) window.addEventListener('resize', measure) window.addEventListener('scroll', measure, true) return () => { observer?.disconnect(); window.removeEventListener('resize', measure); window.removeEventListener('scroll', measure, true) } }, [props.anchor]) useEffect(() => { if (searchAll && !specialView) TEMPLATE_SOURCES.forEach(source => loadSource(source.id)) }, [loadSource, searchAll, specialView]) useEffect(() => { const previous = previousFocus.current return () => { if (previous?.isConnected) previous.focus() } }, []) useEffect(() => { if (shellRef.current) shellRef.current.inert = selected !== null if (selected !== null) detailRef.current?.focus() else searchRef.current?.focus() }, [selected]) const changeWidth = (value: number): void => { const next = Math.max(0.55, Math.min(1, value)) setWidthRatio(next) try { window.localStorage.setItem(DRAWER_WIDTH_KEY, String(next)) } catch { /* optional preference */ } } /** Every favorite keeps its source so the favorites tab can group libraries. */ const favoriteItems = useMemo( () => favorites.map(entry => ({ sourceId: entry.sourceId, item: entry.case })), [favorites], ) const visibleItems = useMemo(() => { if (favoritesView) return favoriteItems if (recentView) return recents if (searchAll) return TEMPLATE_SOURCES.flatMap(source => (lists[source.id]?.cases ?? []).map(item => ({ sourceId: source.id, item }))) return (list?.cases ?? []).map(item => ({ sourceId: activeSource, item })) }, [activeSource, favoriteItems, favoritesView, recentView, recents, searchAll, lists, list]) const favKeys = useMemo(() => new Set(favorites.map(entry => entry.key)), [favorites]) const categories = useMemo(() => { const counts = new Map() for (const { item } of visibleItems) { const entry = counts.get(item.category) ?? { label: item.categoryZh || item.category, count: 0 } entry.count += 1 counts.set(item.category, entry) } return [...counts.entries()].map(([key, value]) => ({ key, label: value.label, count: value.count })) }, [visibleItems]) const filtered = useMemo(() => { const needle = query.trim().toLowerCase() return visibleItems.filter(({ item }) => { if (category !== '' && item.category !== category) return false if (needle === '') return true return item.title.toLowerCase().includes(needle) || item.prompt.toLowerCase().includes(needle) || item.sourceLabel.toLowerCase().includes(needle) }) }, [visibleItems, query, category]) // Isolate the drawer from the canvas shortcuts; Escape returns from details first. useEffect(() => { const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation() if (selected !== null) setSelected(null) else onClose() } else if (event.key === 'Tab') { const container = selected !== null ? detailRef.current : shellRef.current const items = [...(container?.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], [tabindex="0"]') ?? [])].filter(item => !item.hidden) const first = items[0], last = items[items.length - 1] if (event.shiftKey && (document.activeElement === first || document.activeElement === container)) { event.preventDefault(); last?.focus() } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === container)) { event.preventDefault(); first?.focus() } } } window.addEventListener('keydown', onKey, true) return () => window.removeEventListener('keydown', onKey, true) }, [selected, onClose]) const refresh = async (): Promise => { const source = TEMPLATE_SOURCES.find(candidate => candidate.id === activeSource) if (refreshing || source?.refreshable === false) return setRefreshing(true) setNotice(null) try { const result = await api.templatesRefresh(activeSource) const reloaded = await api.templatesList(activeSource) setLists(current => ({ ...current, [activeSource]: reloaded })) setNotice(tt('templates.refreshed', { count: result.total })) } catch (caught) { setNotice(tt('templates.refreshFailed', { error: errorMessage(caught) })) } finally { setRefreshing(false) } } /** Star / unstar one template, preserving its owning source. */ const toggleFavorite = (sourceId: string, item: TemplateCase): void => { const key = favoriteKeyOf(sourceId, item) const pending = favKeys.has(key) ? api.favoritesRemove(key) : api.favoritesAdd(sourceId, item) pending.then(setFavorites).catch(() => { /* leave the star as-is on failure */ }) } /** Mirror every reference image of the active source through the host cache. */ const cacheAllImages = async (): Promise => { if (cacheAll.running || list === undefined) return const files = [...new Set(list.cases.map(item => item.image).filter(name => name !== ''))] setCacheAll({ running: true, done: 0, total: files.length }) let index = 0 const worker = async (): Promise => { while (index < files.length) { const file = files[index]! index += 1 try { await fetch(`${TEMPLATES_API.image}/${encodeURIComponent(activeSource)}/${encodeURIComponent(file)}`) } catch { /* individual failures are retried on the next run */ } setCacheAll(current => ({ ...current, done: current.done + 1 })) } } await Promise.all(Array.from({ length: CACHE_ALL_CONCURRENCY }, () => worker())) setCacheAll({ running: false, done: files.length, total: files.length }) } const copyPrompt = async (text: string): Promise => { try { if (navigator.clipboard?.writeText !== undefined) { await navigator.clipboard.writeText(text) } else { const textarea = document.createElement('textarea') textarea.value = text textarea.style.position = 'fixed' textarea.style.opacity = '0' document.body.appendChild(textarea) textarea.select() const copiedOk = document.execCommand('copy') textarea.remove() if (!copiedOk) throw new Error('copy failed') } setCopied(true) window.setTimeout(() => { setCopied(false) }, 1800) } catch { setCopied(false) } } const renderCard = ({ sourceId, item }: TemplateEntry): React.JSX.Element => { const key = favoriteKeyOf(sourceId, item) const active = favKeys.has(key) return { setSelected({ sourceId, item }); setCopied(false) }} > {item.featured ? {tt('templates.featured')} : null} { toggleFavorite(sourceId, item) }} /> {item.title} {item.categoryZh || item.category} {item.sourceLabel !== '' ? {item.sourceLabel} : null} } const applySelected = (mode: TemplateApplyMode = 'replace'): void => { if (selected === null) return const prompt = selected.item.prompt setRecents(rememberTemplate(selected, recents)) setSelected(null) onUse(prompt, props.appendOnly ? 'append' : mode) onClose() } const originLabel = list === undefined ? '' : tt(list.origin === 'refreshed' ? 'templates.origin.refreshed' : 'templates.origin.bundled') const activeMeta = TEMPLATE_SOURCES.find(source => source.id === activeSource)! return createPortal( event.stopPropagation()} onKeyUp={event => event.stopPropagation()} onPaste={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()} onWheel={event => event.stopPropagation()}> { event.stopPropagation() }}> {!fullscreen ? { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture?.(event.pointerId); setResizing(true) }} onPointerMove={event => { if (!resizing) return; const rect = overlayRef.current?.getBoundingClientRect(); if (rect && rect.width > 0) changeWidth((rect.right - event.clientX) / rect.width) }} onPointerUp={() => setResizing(false)} onPointerCancel={() => setResizing(false)} onLostPointerCapture={() => setResizing(false)} onKeyDown={event => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); changeWidth(widthRatio + (event.key === 'ArrowLeft' ? 0.05 : -0.05)) } }} /> : null} {tt('templates.title')} {specialView ? ( {tt(recentView ? 'templates.recentsCount' : 'templates.favoritesCount', { count: recentView ? recents.length : favorites.length })} ) : list !== undefined ? ( {tt('templates.meta', { count: searchAll ? visibleItems.length : list.total, origin: searchAll ? tt('templates.allSources') : originLabel })} ) : null} setFullscreen(value => !value)}>{fullscreen ? : } {activeMeta.refreshable === false ? null : ( { void refresh() }}> {refreshing ? tt('templates.refreshing') : tt('templates.refresh')} )} { void cacheAllImages() }} > {cacheAll.running ? tt('templates.caching', { done: cacheAll.done, total: cacheAll.total }) : cacheAll.total > 0 && cacheAll.done === cacheAll.total ? tt('templates.cached') : tt('templates.cacheAll')} {TEMPLATE_SOURCES.map(source => ( { switchSource(source.id) }} > {source.label} {lists[source.id] !== undefined ? {lists[source.id]!.total} : null} ))} { setFavoritesView(false); setRecentView(false); setCategory(''); setSelected(null) }}>{tt('templates.browse')} { setFavoritesView(true); setRecentView(false); setCategory(''); setSelected(null); setNotice(null) }}>★ {tt('templates.favorites')}{favorites.length > 0 ? ` ${favorites.length}` : ''} { setRecentView(true); setFavoritesView(false); setCategory(''); setSelected(null); setNotice(null) }}>{tt('templates.recents')} { setQuery(event.target.value) }} /> {!specialView ? { setSearchAll(event.target.value === 'all'); setCategory('') }}>{tt('templates.currentSource')}{tt('templates.allSources')} : null} { setCategory('') }} > {tt('templates.all')}{visibleItems.length > 0 ? ` ${visibleItems.length}` : ''} {categories.map(entry => ( { setCategory(entry.key) }} > {entry.label} {entry.count} ))} {notice !== null ? {notice} : null} {loading && visibleItems.length === 0 ? ( {tt('templates.loading')} ) : null} {!specialView && loadError !== null ? ( 0 ? css.partialError : css.state} role="alert"> {tt('templates.loadFailed', { error: loadError })} { sourceIds.filter(id => loadErrors[id]).forEach(id => loadSource(id, true)) }}> {tt('templates.retry')} ) : null} {specialView && filtered.length === 0 ? ( {tt(query.trim() !== '' || category !== '' ? 'templates.empty' : recentView ? 'templates.recentsEmpty' : 'templates.favoritesEmpty')} ) : null} {!specialView && !loading && loadError === null && filtered.length === 0 ? ( {tt('templates.empty')} ) : null} {favoritesView && filtered.length > 0 ? ( {TEMPLATE_SOURCES.map(source => { const group = filtered.filter(entry => entry.sourceId === source.id) if (group.length === 0) return null return {source.label} {group.length} {group.map(renderCard)} })} ) : null} {!favoritesView && filtered.length > 0 ? ( {filtered.map(renderCard)} ) : null} {selected !== null ? ( { setSelected(null) }}> { event.stopPropagation() }}> {selected.item.image !== '' ? ( ) : ( )} {selected.item.title} {selected.item.categoryZh || selected.item.category} {selected.item.sourceUrl !== '' ? ( {selected.item.sourceLabel || selected.item.sourceUrl} ) : null} {selected.item.githubUrl !== '' ? ( GitHub ) : null} {props.appendOnly ? {tt('templates.ecommerceHint')} : null} {selected.item.prompt} applySelected(props.appendOnly ? 'append' : 'replace')}> {tt(props.appendOnly ? 'templates.applyStyle' : props.hasExistingPrompt ? 'templates.replace' : 'templates.use')} {props.hasExistingPrompt && !props.appendOnly ? applySelected('append')}>{tt('templates.append')} : null} { void copyPrompt(selected.item.prompt) }}> {copied ? tt('templates.copied') : tt('templates.copy')} { toggleFavorite(selected.sourceId, selected.item) }} > {favKeys.has(favoriteKeyOf(selected.sourceId, selected.item)) ? tt('templates.unfavorite') : tt('templates.favorite')} { setSelected(null) }}> {tt('templates.back')} ) : null} , document.body, ) }
{tt('templates.ecommerceHint')}
{selected.item.prompt}