Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 23 additions & 19 deletions ui/src/features/assets/AssetsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ParseKeys } from 'i18next'
import { Box, FileText, Film, Image as ImageIcon, Info, Loader2, Music2, RefreshCw, Search } from 'lucide-react'
import { fetchAsset, fetchAssets, type AssetCatalogItem, type AssetKind } from '../../api/client'
import { useStore } from '../../stores/useStore'
import { useUiTranslation } from '../../i18n'

const PAGE_SIZE = 100
const kinds: Array<{ value: AssetKind | ''; label: string }> = [
{ value: '', label: 'Todos' },
{ value: 'image', label: 'Imágenes' },
{ value: 'video', label: 'Vídeos' },
{ value: 'audio', label: 'Audio' },
{ value: 'model3d', label: '3D' },
{ value: 'scene', label: 'Escenas' },
{ value: 'document', label: 'Documentos' },
const KIND_FILTERS: Array<{ value: AssetKind | ''; labelKey: ParseKeys<'navigation'> }> = [
{ value: '', labelKey: 'tabs.all' },
{ value: 'image', labelKey: 'tabs.images' },
{ value: 'video', labelKey: 'tabs.videos' },
{ value: 'audio', labelKey: 'tabs.audio' },
{ value: 'model3d', labelKey: 'tabs.model3d' },
{ value: 'scene', labelKey: 'tabs.scenes' },
{ value: 'document', labelKey: 'tabs.documents' },
]

function icon(kind: AssetKind) {
Expand Down Expand Up @@ -52,6 +53,7 @@ function AssetPreview({ asset }: { asset: AssetCatalogItem }) {
export function AssetsPanel() {
const { t } = useUiTranslation('navigation')
const { t: tActivity } = useUiTranslation('activity')
const { t: tCommon } = useUiTranslation('common')
const workspaces = useStore(state => state.workspaces)
const loadWorkspaces = useStore(state => state.loadWorkspaces)
const [assets, setAssets] = useState<AssetCatalogItem[]>([])
Expand Down Expand Up @@ -84,15 +86,15 @@ export function AssetsPanel() {
setTotal(result.total)
} catch (reason) {
if (request === requestRef.current) {
setError(reason instanceof Error ? reason.message : 'No se pudo cargar el catálogo')
setError(reason instanceof Error ? reason.message : tActivity('catalog.loadFailed'))
}
} finally {
if (request === requestRef.current) {
setLoading(false)
setLoadingMore(false)
}
}
}, [kind, search, workspace])
}, [kind, search, tActivity, workspace])

useEffect(() => { void loadWorkspaces() }, [loadWorkspaces])
useEffect(() => { void load(false) }, [kind, load, search, workspace])
Expand Down Expand Up @@ -120,7 +122,7 @@ export function AssetsPanel() {
<div className="flex items-center gap-1.5 text-sm font-semibold text-text-primary">
<Box size={16} className="text-accent-blue" /> {t('headings.assets')}
</div>
<span className="text-[10px] text-text-muted">{total} elementos en todas las ubicaciones</span>
<span className="text-[10px] text-text-muted">{tActivity('catalog.itemsAcrossLocations', { count: total })}</span>
<div className="min-w-0 flex-1" />
<form
className="flex items-center gap-1 rounded-md border border-border bg-bg-secondary px-2"
Expand All @@ -130,20 +132,21 @@ export function AssetsPanel() {
<input
value={draftSearch}
onChange={event => setDraftSearch(event.target.value)}
placeholder="Buscar prompt, modelo, herramienta…"
placeholder={tActivity('catalog.searchPlaceholder')}
aria-label={tCommon('actions.search')}
className="w-52 bg-transparent py-1.5 text-xs text-text-primary outline-none"
/>
</form>
<select
aria-label="Asset kind"
aria-label={tActivity('catalog.kindFilter')}
value={kind}
onChange={event => setKind(event.target.value as AssetKind | '')}
className="rounded-md border border-border bg-bg-secondary px-2 py-1.5 text-xs text-text-primary"
>
{kinds.map(item => <option key={item.value} value={item.value}>{item.label}</option>)}
{KIND_FILTERS.map(item => <option key={item.value} value={item.value}>{t(item.labelKey)}</option>)}
</select>
<select
aria-label="Asset workspace"
aria-label={tActivity('catalog.workspaceFilter')}
value={workspace}
onChange={event => setWorkspace(event.target.value)}
className="rounded-md border border-border bg-bg-secondary px-2 py-1.5 text-xs text-text-primary"
Expand All @@ -154,7 +157,8 @@ export function AssetsPanel() {
type="button"
onClick={() => void load(false)}
className="rounded-md border border-border bg-bg-secondary p-1.5 text-text-muted hover:text-text-primary"
title="Actualizar catálogo"
title={tActivity('catalog.reload')}
aria-label={tCommon('actions.reload')}
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
Expand All @@ -163,7 +167,7 @@ export function AssetsPanel() {
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{error && <div role="alert" className="mb-3 rounded-md border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-300">{error}</div>}
{loading && !assets.length ? (
<div className="flex h-full items-center justify-center gap-2 text-xs text-text-muted"><Loader2 size={16} className="animate-spin" /> Leyendo el catálogo…</div>
<div className="flex h-full items-center justify-center gap-2 text-xs text-text-muted"><Loader2 size={16} className="animate-spin" /> {tActivity('catalog.reading')}</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{assets.map(asset => (
Expand Down Expand Up @@ -191,15 +195,15 @@ export function AssetsPanel() {
))}
</div>
)}
{!loading && !assets.length && !error && <p className="p-8 text-center text-xs text-text-muted">No hay assets que coincidan con estos filtros.</p>}
{!loading && !assets.length && !error && <p className="p-8 text-center text-xs text-text-muted">{tActivity('catalog.empty')}</p>}
{assets.length < total && (
<button
type="button"
disabled={loadingMore}
onClick={() => void load(true, assets.length)}
className="mx-auto mt-4 flex items-center gap-2 rounded-md border border-border bg-bg-secondary px-4 py-2 text-xs text-text-secondary disabled:opacity-50"
>
{loadingMore && <Loader2 size={13} className="animate-spin" />} Cargar más
{loadingMore && <Loader2 size={13} className="animate-spin" />} {tActivity('catalog.loadMore')}
</button>
)}
</div>
Expand Down
12 changes: 12 additions & 0 deletions ui/src/i18n/locales/en/activity.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@
"loadFailed": "Could not load Extra info",
"prompt": "Prompt · {{name}}"
},
"catalog": {
"itemsAcrossLocations_one": "{{count}} item across all locations",
"itemsAcrossLocations_other": "{{count}} items across all locations",
"searchPlaceholder": "Search prompt, model, tool…",
"kindFilter": "Asset kind",
"workspaceFilter": "Asset workspace",
"reload": "Refresh catalog",
"loadFailed": "Could not load the catalog",
"reading": "Reading the catalog…",
"empty": "No assets match these filters.",
"loadMore": "Load more"
},
"collectionsHint": "Reference collections. They are not output folders.",
"projectCount_one": "{{count}} project",
"projectCount_other": "{{count}} projects",
Expand Down
1 change: 1 addition & 0 deletions ui/src/i18n/locales/en/navigation.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"audio": "Audio",
"model3d": "3D",
"scenes": "Scenes",
"documents": "Documents",
"storyLab": "Story Lab",
"seriesLab": "Series Lab",
"runs": "Runs",
Expand Down
12 changes: 12 additions & 0 deletions ui/src/i18n/locales/es/activity.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@
"loadFailed": "No se pudo cargar Información adicional",
"prompt": "Prompt · {{name}}"
},
"catalog": {
"itemsAcrossLocations_one": "{{count}} elemento en todas las ubicaciones",
"itemsAcrossLocations_other": "{{count}} elementos en todas las ubicaciones",
"searchPlaceholder": "Buscar prompt, modelo, herramienta…",
"kindFilter": "Tipo de recurso",
"workspaceFilter": "Workspace del recurso",
"reload": "Actualizar catálogo",
"loadFailed": "No se pudo cargar el catálogo",
"reading": "Leyendo el catálogo…",
"empty": "No hay assets que coincidan con estos filtros.",
"loadMore": "Cargar más"
},
"collectionsHint": "Colecciones de referencias. No son carpetas de salida.",
"projectCount_one": "{{count}} proyecto",
"projectCount_other": "{{count}} proyectos",
Expand Down
1 change: 1 addition & 0 deletions ui/src/i18n/locales/es/navigation.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"audio": "Audio",
"model3d": "3D",
"scenes": "Escenas",
"documents": "Documentos",
"storyLab": "Story Lab",
"seriesLab": "Series Lab",
"runs": "Ejecuciones",
Expand Down
27 changes: 27 additions & 0 deletions ui/tests/assetsCatalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,34 @@ test('Assets is a first-class tab with its own panel, not a fake active workspac
assert.match(panel, /tActivity\('inspector\.fullJson'\)/)
assert.match(panel, /tActivity\('inspector\.loadFailed'\)/)
assert.match(panel, /tCommon\('actions\.close'\)/)
assert.match(panel, /labelKey: 'tabs\.all'/)
assert.match(panel, /labelKey: 'tabs\.images'/)
assert.match(panel, /labelKey: 'tabs\.videos'/)
assert.match(panel, /labelKey: 'tabs\.audio'/)
assert.match(panel, /labelKey: 'tabs\.model3d'/)
assert.match(panel, /labelKey: 'tabs\.scenes'/)
assert.match(panel, /labelKey: 'tabs\.documents'/)
assert.match(panel, /tActivity\('catalog\.itemsAcrossLocations'/)
assert.match(panel, /tActivity\('catalog\.searchPlaceholder'\)/)
assert.match(panel, /tActivity\('catalog\.kindFilter'\)/)
assert.match(panel, /tActivity\('catalog\.workspaceFilter'\)/)
assert.match(panel, /tActivity\('catalog\.reload'\)/)
assert.match(panel, /tActivity\('catalog\.loadFailed'\)/)
assert.match(panel, /tActivity\('catalog\.reading'\)/)
assert.match(panel, /tActivity\('catalog\.empty'\)/)
assert.match(panel, /tActivity\('catalog\.loadMore'\)/)
assert.match(panel, /tCommon\('actions\.search'\)/)
assert.match(panel, /tCommon\('actions\.reload'\)/)
assert.doesNotMatch(panel, /JSON completo/)
assert.doesNotMatch(panel, /No se pudo cargar Extra info/)
assert.doesNotMatch(panel, /elementos en todas las ubicaciones/)
assert.doesNotMatch(panel, /Buscar prompt, modelo, herramienta/)
assert.doesNotMatch(panel, /Leyendo el catálogo/)
assert.doesNotMatch(panel, /No se pudo cargar el catálogo/)
assert.doesNotMatch(panel, /No hay assets que coincidan/)
assert.doesNotMatch(panel, /Actualizar catálogo/)
assert.doesNotMatch(panel, /Cargar más/)
assert.doesNotMatch(panel, /['"`]Asset kind['"`]/)
assert.doesNotMatch(panel, /['"`]Asset workspace['"`]/)
assert.doesNotMatch(panel, /setActiveWorkspace|switchWorkspace/)
})
11 changes: 11 additions & 0 deletions ui/tests/i18nFoundation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ test('required glossary keys exist in both languages', async () => {
['navigation', 'entities.outputFolder'],
['navigation', 'filters.allWorkspaces'],
['navigation', 'outputFolder.uploads'],
['navigation', 'tabs.documents'],
['activity', 'extraInfo'],
['activity', 'catalog.loadMore'],
['activity', 'catalog.searchPlaceholder'],
['common', 'sample.named'],
['navigation', 'labs.story'],
['navigation', 'labs.series'],
Expand All @@ -69,7 +72,10 @@ test('required glossary keys exist in both languages', async () => {
assert.equal(i18n.t('title', { ns: 'wizard', lng: 'es' }), 'Pregunta al mago')
assert.equal(i18n.t('entities.outputFolder', { ns: 'navigation', lng: 'es' }), 'Carpeta de salida')
assert.equal(i18n.t('outputFolder.uploads', { ns: 'navigation', lng: 'es' }), 'Subidas')
assert.equal(i18n.t('tabs.documents', { ns: 'navigation', lng: 'es' }), 'Documentos')
assert.equal(i18n.t('extraInfo', { ns: 'activity', lng: 'es' }), 'Información adicional')
assert.equal(i18n.t('catalog.loadMore', { ns: 'activity', lng: 'es' }), 'Cargar más')
assert.equal(i18n.t('catalog.reload', { ns: 'activity', lng: 'es' }), 'Actualizar catálogo')
assert.equal(i18n.t('inspector.loadFailed', { ns: 'activity', lng: 'en' }), 'Could not load Extra info')
assert.equal(i18n.t('inspector.loadFailed', { ns: 'activity', lng: 'es' }), 'No se pudo cargar Información adicional')
assert.equal(i18n.t('actions.close', { ns: 'common', lng: 'es' }), 'Cerrar')
Expand All @@ -96,6 +102,11 @@ test('interpolation and pluralization stay in the catalog', async () => {
assert.equal(i18n.t('assetCount', { ns: 'activity', count: 1 }), '1 recurso')
assert.equal(i18n.t('productionCount', { ns: 'activity', count: 1 }), '1 producción')
assert.equal(i18n.t('productionCount', { ns: 'activity', count: 2 }), '2 producciones')
assert.equal(i18n.t('catalog.itemsAcrossLocations', { ns: 'activity', count: 1 }), '1 elemento en todas las ubicaciones')
assert.equal(i18n.t('catalog.itemsAcrossLocations', { ns: 'activity', count: 3 }), '3 elementos en todas las ubicaciones')
await i18n.changeLanguage('en')
assert.equal(i18n.t('catalog.itemsAcrossLocations', { ns: 'activity', count: 1 }), '1 item across all locations')
assert.equal(i18n.t('catalog.itemsAcrossLocations', { ns: 'activity', count: 3 }), '3 items across all locations')
})

test('react interpolation keeps special characters as text without double-escaping', async () => {
Expand Down