@lokvis/ui-react 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/components/AssetPanel.d.ts.map +1 -1
  2. package/dist/components/AssetPanel.js +18 -62
  3. package/dist/components/AssetPanel.js.map +1 -1
  4. package/dist/components/Canvas.d.ts +4 -4
  5. package/dist/components/Canvas.d.ts.map +1 -1
  6. package/dist/components/Canvas.js +9 -9
  7. package/dist/components/Canvas.js.map +1 -1
  8. package/dist/components/CommandPalette.d.ts +7 -7
  9. package/dist/components/CommandPalette.d.ts.map +1 -1
  10. package/dist/components/CommandPalette.js +10 -10
  11. package/dist/components/CommandPalette.js.map +1 -1
  12. package/dist/components/CompareSlider.d.ts +9 -8
  13. package/dist/components/CompareSlider.d.ts.map +1 -1
  14. package/dist/components/CompareSlider.js +10 -9
  15. package/dist/components/CompareSlider.js.map +1 -1
  16. package/dist/components/DownloadPanel.d.ts +3 -3
  17. package/dist/components/DownloadPanel.d.ts.map +1 -1
  18. package/dist/components/DownloadPanel.js +10 -17
  19. package/dist/components/DownloadPanel.js.map +1 -1
  20. package/dist/components/ErrorBanner.d.ts +2 -2
  21. package/dist/components/ErrorBanner.d.ts.map +1 -1
  22. package/dist/components/ErrorBanner.js +3 -3
  23. package/dist/components/ErrorBanner.js.map +1 -1
  24. package/dist/components/ErrorBoundary.d.ts +40 -0
  25. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  26. package/dist/components/ErrorBoundary.js +52 -0
  27. package/dist/components/ErrorBoundary.js.map +1 -0
  28. package/dist/components/ExifPanel.d.ts +1 -1
  29. package/dist/components/ExifPanel.d.ts.map +1 -1
  30. package/dist/components/ExifPanel.js +2 -2
  31. package/dist/components/ExifPanel.js.map +1 -1
  32. package/dist/components/GlobalDropzone.d.ts +5 -5
  33. package/dist/components/GlobalDropzone.d.ts.map +1 -1
  34. package/dist/components/GlobalDropzone.js +15 -8
  35. package/dist/components/GlobalDropzone.js.map +1 -1
  36. package/dist/components/HistoryPanel.d.ts +2 -2
  37. package/dist/components/HistoryPanel.d.ts.map +1 -1
  38. package/dist/components/HistoryPanel.js +23 -23
  39. package/dist/components/HistoryPanel.js.map +1 -1
  40. package/dist/components/Inspector.d.ts.map +1 -1
  41. package/dist/components/Inspector.js +13 -10
  42. package/dist/components/Inspector.js.map +1 -1
  43. package/dist/components/ParamForm.d.ts.map +1 -1
  44. package/dist/components/ParamForm.js +10 -7
  45. package/dist/components/ParamForm.js.map +1 -1
  46. package/dist/components/PipelineBar.d.ts.map +1 -1
  47. package/dist/components/PipelineBar.js +19 -28
  48. package/dist/components/PipelineBar.js.map +1 -1
  49. package/dist/components/ProgressBar.d.ts +4 -4
  50. package/dist/components/ProgressBar.d.ts.map +1 -1
  51. package/dist/components/ProgressBar.js +8 -8
  52. package/dist/components/ProgressBar.js.map +1 -1
  53. package/dist/components/StatusBar.d.ts +3 -3
  54. package/dist/components/StatusBar.d.ts.map +1 -1
  55. package/dist/components/StatusBar.js +12 -21
  56. package/dist/components/StatusBar.js.map +1 -1
  57. package/dist/components/StatusDot.d.ts +12 -0
  58. package/dist/components/StatusDot.d.ts.map +1 -0
  59. package/dist/components/StatusDot.js +15 -0
  60. package/dist/components/StatusDot.js.map +1 -0
  61. package/dist/components/ThemeToggle.d.ts +4 -4
  62. package/dist/components/ThemeToggle.d.ts.map +1 -1
  63. package/dist/components/ThemeToggle.js +7 -7
  64. package/dist/components/ThemeToggle.js.map +1 -1
  65. package/dist/components/ToolRunner.d.ts +53 -0
  66. package/dist/components/ToolRunner.d.ts.map +1 -0
  67. package/dist/components/ToolRunner.js +142 -0
  68. package/dist/components/ToolRunner.js.map +1 -0
  69. package/dist/components/Toolbar.d.ts.map +1 -1
  70. package/dist/components/Toolbar.js +3 -3
  71. package/dist/components/Toolbar.js.map +1 -1
  72. package/dist/components/WorkflowEditor.d.ts +6 -6
  73. package/dist/components/WorkflowEditor.d.ts.map +1 -1
  74. package/dist/components/WorkflowEditor.js +40 -49
  75. package/dist/components/WorkflowEditor.js.map +1 -1
  76. package/dist/components/WorkflowTemplates.d.ts +2 -2
  77. package/dist/components/WorkflowTemplates.d.ts.map +1 -1
  78. package/dist/components/WorkflowTemplates.js +17 -7
  79. package/dist/components/WorkflowTemplates.js.map +1 -1
  80. package/dist/components/Workspace.d.ts +45 -23
  81. package/dist/components/Workspace.d.ts.map +1 -1
  82. package/dist/components/Workspace.js +87 -40
  83. package/dist/components/Workspace.js.map +1 -1
  84. package/dist/hooks/useCustomPresets.d.ts +98 -0
  85. package/dist/hooks/useCustomPresets.d.ts.map +1 -0
  86. package/dist/hooks/useCustomPresets.js +210 -0
  87. package/dist/hooks/useCustomPresets.js.map +1 -0
  88. package/dist/hooks/useLokvis.d.ts +19 -1
  89. package/dist/hooks/useLokvis.d.ts.map +1 -1
  90. package/dist/hooks/useLokvis.js +30 -9
  91. package/dist/hooks/useLokvis.js.map +1 -1
  92. package/dist/index.d.ts +4 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +6 -0
  95. package/dist/index.js.map +1 -1
  96. package/dist/store/assets-slice.d.ts +8 -1
  97. package/dist/store/assets-slice.d.ts.map +1 -1
  98. package/dist/store/assets-slice.js +101 -54
  99. package/dist/store/assets-slice.js.map +1 -1
  100. package/dist/store/capabilities-slice.d.ts +1 -1
  101. package/dist/store/capabilities-slice.d.ts.map +1 -1
  102. package/dist/store/capabilities-slice.js +8 -1
  103. package/dist/store/capabilities-slice.js.map +1 -1
  104. package/dist/store/index.d.ts +1 -1
  105. package/dist/store/index.d.ts.map +1 -1
  106. package/dist/store/index.js +1 -1
  107. package/dist/store/index.js.map +1 -1
  108. package/dist/store/runtime-slice.d.ts.map +1 -1
  109. package/dist/store/runtime-slice.js +22 -16
  110. package/dist/store/runtime-slice.js.map +1 -1
  111. package/dist/store/subscribe-utils.d.ts +45 -0
  112. package/dist/store/subscribe-utils.d.ts.map +1 -0
  113. package/dist/store/subscribe-utils.js +69 -0
  114. package/dist/store/subscribe-utils.js.map +1 -0
  115. package/dist/store/types.d.ts +19 -11
  116. package/dist/store/types.d.ts.map +1 -1
  117. package/dist/store/types.js +0 -10
  118. package/dist/store/types.js.map +1 -1
  119. package/dist/store/workflow-slice.d.ts +1 -1
  120. package/dist/store/workflow-slice.d.ts.map +1 -1
  121. package/dist/store/workflow-slice.js +19 -51
  122. package/dist/store/workflow-slice.js.map +1 -1
  123. package/dist/utils.d.ts +24 -0
  124. package/dist/utils.d.ts.map +1 -0
  125. package/dist/utils.js +31 -0
  126. package/dist/utils.js.map +1 -0
  127. package/package.json +7 -6
  128. package/src/components/AssetPanel.tsx +224 -264
  129. package/src/components/Canvas.tsx +178 -174
  130. package/src/components/CommandPalette.tsx +242 -242
  131. package/src/components/CompareSlider.tsx +168 -165
  132. package/src/components/DownloadPanel.tsx +201 -207
  133. package/src/components/ErrorBanner.tsx +64 -64
  134. package/src/components/ErrorBoundary.tsx +101 -0
  135. package/src/components/ExifPanel.tsx +154 -154
  136. package/src/components/GlobalDropzone.tsx +175 -170
  137. package/src/components/HistoryPanel.tsx +219 -219
  138. package/src/components/Inspector.tsx +145 -137
  139. package/src/components/ParamForm.tsx +140 -137
  140. package/src/components/PipelineBar.tsx +97 -104
  141. package/src/components/ProgressBar.tsx +78 -78
  142. package/src/components/StatusBar.tsx +145 -152
  143. package/src/components/StatusDot.tsx +27 -0
  144. package/src/components/ThemeToggle.tsx +98 -98
  145. package/src/components/ToolRunner.tsx +352 -0
  146. package/src/components/Toolbar.tsx +65 -65
  147. package/src/components/WorkflowEditor.tsx +333 -326
  148. package/src/components/WorkflowTemplates.tsx +101 -78
  149. package/src/components/Workspace.tsx +310 -223
  150. package/src/css-modules.d.ts +3 -0
  151. package/src/hooks/useCustomPresets.ts +284 -0
  152. package/src/hooks/useLokvis.ts +43 -10
  153. package/src/index.ts +7 -0
  154. package/src/store/assets-slice.ts +106 -52
  155. package/src/store/capabilities-slice.ts +10 -2
  156. package/src/store/index.ts +1 -1
  157. package/src/store/runtime-slice.ts +20 -15
  158. package/src/store/subscribe-utils.ts +82 -0
  159. package/src/store/types.ts +19 -12
  160. package/src/store/workflow-slice.ts +19 -53
  161. package/src/utils.ts +41 -0
  162. package/dist/store.d.ts +0 -76
  163. package/dist/store.d.ts.map +0 -1
  164. package/dist/store.js +0 -217
  165. package/dist/store.js.map +0 -1
@@ -7,287 +7,247 @@
7
7
 
8
8
  import * as React from 'react';
9
9
  import type { AssetType } from '@lokvis/schema';
10
- import { Icon } from '@lokvis/ui-core';
10
+ import { FOCUS_RING, Icon, Input } from '@lokvis/ui-core';
11
11
  import { useWorkspaceStore } from '../store/index.js';
12
12
 
13
13
  export interface AssetPanelProps {
14
- className?: string;
14
+ className?: string;
15
15
  }
16
16
 
17
17
  /** 类型筛选 chip 显示名 */
18
18
  const TYPE_LABEL: Record<AssetType, string> = {
19
- image: 'Image',
20
- video: 'Video',
21
- audio: 'Audio',
22
- pdf: 'PDF',
23
- text: 'Text',
24
- data: 'Data',
25
- unknown: 'Other',
19
+ image: 'Image',
20
+ video: 'Video',
21
+ audio: 'Audio',
22
+ pdf: 'PDF',
23
+ text: 'Text',
24
+ data: 'Data',
25
+ unknown: 'Other',
26
26
  };
27
27
 
28
28
  export function AssetPanel({ className = '' }: AssetPanelProps) {
29
- const assets = useWorkspaceStore((s) => s.assets);
30
- const selectedAssetId = useWorkspaceStore((s) => s.selectedAssetId);
31
- const thumbnails = useWorkspaceStore((s) => s.thumbnails);
32
- const importFiles = useWorkspaceStore((s) => s.importFiles);
33
- const selectAsset = useWorkspaceStore((s) => s.selectAsset);
34
- const removeAsset = useWorkspaceStore((s) => s.removeAsset);
35
- const setThumbnail = useWorkspaceStore((s) => s.setThumbnail);
36
- const runtime = useWorkspaceStore((s) => s.runtime);
29
+ const assets = useWorkspaceStore((s) => s.assets);
30
+ const selectedAssetId = useWorkspaceStore((s) => s.selectedAssetId);
31
+ const thumbnails = useWorkspaceStore((s) => s.thumbnails);
32
+ const importFiles = useWorkspaceStore((s) => s.importFiles);
33
+ const selectAsset = useWorkspaceStore((s) => s.selectAsset);
34
+ const removeAsset = useWorkspaceStore((s) => s.removeAsset);
35
+ const ensureThumbnails = useWorkspaceStore((s) => s.ensureThumbnails);
37
36
 
38
- const [dragging, setDragging] = React.useState(false);
39
- // W6.5 筛选:类型 + 关键词(纯 UI 状态,不入 store)
40
- const [filterType, setFilterType] = React.useState<'all' | AssetType>('all');
41
- const [searchQuery, setSearchQuery] = React.useState('');
42
- // B2 修复:跟踪 in-flight exportAsset,避免同一 asset 跨 effect 重跑并发发起
43
- const inflightRef = React.useRef<Set<string>>(new Set());
37
+ const [dragging, setDragging] = React.useState(false);
38
+ // W6.5 筛选:类型 + 关键词(纯 UI 状态,不入 store)
39
+ const [filterType, setFilterType] = React.useState<'all' | AssetType>('all');
40
+ const [searchQuery, setSearchQuery] = React.useState('');
44
41
 
45
- async function handleFiles(files: FileList | File[] | null) {
46
- if (!files) return;
47
- const arr = Array.from(files);
48
- if (arr.length > 0) await importFiles(arr);
49
- }
42
+ async function handleFiles(files: FileList | File[] | null) {
43
+ if (!files) return;
44
+ const arr = Array.from(files);
45
+ if (arr.length > 0) await importFiles(arr);
46
+ }
50
47
 
51
- // 自动生成缩略图
52
- // B2 修复:
53
- // - cancelled flag:effect 重跑/卸载时,await 完成的 exportAsset 不再创建
54
- // ObjectURL 也不 setThumbnail,避免覆盖更新值与孤儿 URL
55
- // - inflightRef:同一 asset 在上一轮 effect 还在 await 时,新一轮 effect
56
- // 不再重复发起(批量导入 assets 频繁变化场景)
57
- React.useEffect(() => {
58
- if (!runtime) return;
59
- let cancelled = false;
60
- const inflight = inflightRef.current;
61
- for (const asset of assets) {
62
- if (thumbnails[asset.id]) continue;
63
- if (asset.type !== 'image') continue;
64
- if (inflight.has(asset.id)) continue;
65
- inflight.add(asset.id);
66
- (async () => {
67
- try {
68
- const blob = await runtime.exportAsset(asset.id);
69
- // effect 已过期或资产已删除:不创建 ObjectURL(零泄漏)
70
- if (cancelled) return;
71
- const url = URL.createObjectURL(blob);
72
- if (cancelled) {
73
- // 双重检查:await 与 createObjectURL 之间可能被取消
74
- URL.revokeObjectURL(url);
75
- return;
76
- }
77
- setThumbnail(asset.id, url);
78
- } catch {
79
- // ignore export 失败
80
- } finally {
81
- inflight.delete(asset.id);
82
- }
83
- })();
84
- }
85
- return () => {
86
- cancelled = true;
87
- };
88
- // eslint-disable-next-line react-hooks/exhaustive-deps
89
- }, [assets, runtime]);
48
+ // TD-5.1:缩略图生成统一在 store.ensureThumbnails 中管理(创建/替换/释放闭环)。
49
+ // 组件只需在 assets 变化时触发一次,inflight 去重 + 资产存在性检查在 store 内完成。
50
+ React.useEffect(() => {
51
+ ensureThumbnails();
52
+ }, [assets, ensureThumbnails]);
90
53
 
91
- // 资产中实际存在的类型(仅展示有意义的 chip,避免空类型噪音)
92
- const availableTypes = React.useMemo(() => {
93
- const set = new Set<AssetType>();
94
- for (const a of assets) set.add(a.type);
95
- return Array.from(set);
96
- }, [assets]);
54
+ // 资产中实际存在的类型(仅展示有意义的 chip,避免空类型噪音)
55
+ const availableTypes = React.useMemo(() => {
56
+ const set = new Set<AssetType>();
57
+ for (const a of assets) set.add(a.type);
58
+ return Array.from(set);
59
+ }, [assets]);
97
60
 
98
- // 筛选后的资产列表
99
- const filteredAssets = React.useMemo(() => {
100
- let result = assets;
101
- if (filterType !== 'all') {
102
- result = result.filter((a) => a.type === filterType);
103
- }
104
- const q = searchQuery.trim().toLowerCase();
105
- if (q) {
106
- result = result.filter(
107
- (a) =>
108
- a.metadata.format.toLowerCase().includes(q) ||
109
- a.metadata.mimeType.toLowerCase().includes(q)
110
- );
111
- }
112
- return result;
113
- }, [assets, filterType, searchQuery]);
61
+ // 筛选后的资产列表
62
+ const filteredAssets = React.useMemo(() => {
63
+ let result = assets;
64
+ if (filterType !== 'all') {
65
+ result = result.filter((a) => a.type === filterType);
66
+ }
67
+ const q = searchQuery.trim().toLowerCase();
68
+ if (q) {
69
+ result = result.filter(
70
+ (a) =>
71
+ a.metadata.format.toLowerCase().includes(q) ||
72
+ a.metadata.mimeType.toLowerCase().includes(q)
73
+ );
74
+ }
75
+ return result;
76
+ }, [assets, filterType, searchQuery]);
114
77
 
115
- return (
116
- <aside
117
- className={`flex w-56 shrink-0 flex-col border-r border-zinc-200 dark:border-zinc-800 ${className}`}
118
- onDragOver={(e) => {
119
- e.preventDefault();
120
- setDragging(true);
121
- }}
122
- onDragLeave={() => setDragging(false)}
123
- onDrop={(e) => {
124
- e.preventDefault();
125
- setDragging(false);
126
- void handleFiles(e.dataTransfer.files);
127
- }}
128
- >
129
- {/* Header */}
130
- <div className="flex items-center justify-between px-3 h-10 shrink-0 border-b border-zinc-200 dark:border-zinc-800">
131
- <span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
132
- Assets
133
- </span>
134
- <span className="text-[11px] tabular-nums text-zinc-400">
135
- {filterType === 'all' && !searchQuery
136
- ? assets.length
137
- : `${filteredAssets.length}/${assets.length}`}
138
- </span>
139
- </div>
78
+ return (
79
+ <aside
80
+ className={`flex w-56 shrink-0 flex-col border-r border-[var(--lokvis-border)] ${className}`}
81
+ onDragOver={(e) => {
82
+ e.preventDefault();
83
+ setDragging(true);
84
+ }}
85
+ onDragLeave={() => setDragging(false)}
86
+ onDrop={(e) => {
87
+ e.preventDefault();
88
+ setDragging(false);
89
+ void handleFiles(e.dataTransfer.files);
90
+ }}
91
+ >
92
+ {/* Header */}
93
+ <div className="flex items-center justify-between px-3 h-[var(--lokvis-panel-header-h)] shrink-0 border-b border-[var(--lokvis-border)]">
94
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--lokvis-fg-subtle)]">
95
+ Assets
96
+ </span>
97
+ <span className="text-[11px] tabular-nums text-[var(--lokvis-fg-subtle)]">
98
+ {filterType === 'all' && !searchQuery
99
+ ? assets.length
100
+ : `${filteredAssets.length}/${assets.length}`}
101
+ </span>
102
+ </div>
140
103
 
141
- {/* Upload zone */}
142
- <label
143
- className={`m-2 block cursor-pointer rounded-lg border border-dashed px-2 py-3 text-center transition-all ${
144
- dragging
145
- ? 'border-indigo-400 bg-indigo-50 text-indigo-600 dark:bg-indigo-950/30'
146
- : 'border-zinc-200 text-zinc-400 hover:border-indigo-300 hover:bg-indigo-50/50 hover:text-indigo-500 dark:border-zinc-700 dark:hover:border-indigo-800 dark:hover:bg-indigo-950/20'
147
- }`}
148
- >
149
- <input
150
- type="file"
151
- multiple
152
- className="hidden"
153
- onChange={(e) => handleFiles(e.target.files)}
154
- />
155
- <Icon size={16} className="mx-auto">
156
- <path d="M12 4.5v15m7.5-7.5h-15" />
157
- </Icon>
158
- <span className="mt-1 block text-[10px] font-medium">
159
- {dragging ? 'Drop here' : 'Add files'}
160
- </span>
161
- </label>
104
+ {/* Upload zone */}
105
+ <label
106
+ className={`m-2 block cursor-pointer rounded-lg border border-dashed px-2 py-3 text-center transition-all ${
107
+ dragging
108
+ ? 'border-[var(--lokvis-primary)] bg-[var(--lokvis-primary)]/10 text-[var(--lokvis-primary)]'
109
+ : 'border-[var(--lokvis-border)] text-[var(--lokvis-fg-subtle)] hover:border-[var(--lokvis-primary)]/50 hover:bg-[var(--lokvis-primary)]/5 hover:text-[var(--lokvis-primary)]'
110
+ }`}
111
+ >
112
+ <input
113
+ type="file"
114
+ multiple
115
+ className="hidden"
116
+ onChange={(e) => handleFiles(e.target.files)}
117
+ />
118
+ <Icon size={16} className="mx-auto">
119
+ <path d="M12 4.5v15m7.5-7.5h-15" />
120
+ </Icon>
121
+ <span className="mt-1 block text-[10px] font-medium">
122
+ {dragging ? 'Drop here' : 'Add files'}
123
+ </span>
124
+ </label>
162
125
 
163
- {/* W6.5 筛选栏:仅当有资产时显示 */}
164
- {assets.length > 0 && (
165
- <div className="px-2 pb-2 space-y-1.5">
166
- {/* 类型 chips:m10 radiogroup 语义,屏幕阅读器识别为单选组 */}
167
- <div
168
- role="radiogroup"
169
- aria-label="Filter by type"
170
- className="flex flex-wrap gap-1"
171
- >
172
- <button
173
- type="button"
174
- role="radio"
175
- aria-checked={filterType === 'all'}
176
- onClick={() => setFilterType('all')}
177
- className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
178
- filterType === 'all'
179
- ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
180
- : 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700'
181
- }`}
182
- >
183
- All
184
- </button>
185
- {availableTypes.map((t) => (
186
- <button
187
- key={t}
188
- type="button"
189
- role="radio"
190
- aria-checked={filterType === t}
191
- onClick={() => setFilterType(t)}
192
- className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
193
- filterType === t
194
- ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
195
- : 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700'
196
- }`}
197
- >
198
- {TYPE_LABEL[t]}
199
- </button>
200
- ))}
201
- </div>
202
- {/* 搜索框:m4 文案改为与实际过滤行为一致(按 format/mimeType) */}
203
- <div className="relative">
204
- <Icon
205
- size={11}
206
- className="absolute left-1.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none"
207
- >
208
- <circle cx="10" cy="10" r="6" />
209
- <path d="m20 20-5-5" />
210
- </Icon>
211
- <input
212
- type="text"
213
- value={searchQuery}
214
- onChange={(e) => setSearchQuery(e.target.value)}
215
- placeholder="Filter by type..."
216
- aria-label="Filter assets by format"
217
- className="w-full rounded bg-zinc-100 py-1 pl-6 pr-1.5 text-[10px] text-zinc-700 placeholder:text-zinc-400 focus:outline-none focus:ring-1 focus:ring-indigo-300 dark:bg-zinc-800 dark:text-zinc-200 dark:placeholder:text-zinc-500"
218
- />
219
- </div>
220
- </div>
221
- )}
126
+ {/* W6.5 筛选栏:仅当有资产时显示 */}
127
+ {assets.length > 0 && (
128
+ <div className="px-2 pb-2 space-y-1.5">
129
+ {/* 类型 chips:m10 radiogroup 语义,屏幕阅读器识别为单选组 */}
130
+ <div
131
+ role="radiogroup"
132
+ aria-label="Filter by type"
133
+ className="flex flex-wrap gap-1"
134
+ >
135
+ <button
136
+ type="button"
137
+ role="radio"
138
+ aria-checked={filterType === 'all'}
139
+ onClick={() => setFilterType('all')}
140
+ className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
141
+ filterType === 'all'
142
+ ? 'bg-[var(--lokvis-primary)]/15 text-[var(--lokvis-primary)]'
143
+ : 'bg-[var(--lokvis-surface-muted)] text-[var(--lokvis-fg-muted)] hover:bg-[var(--lokvis-border)]'
144
+ }`}
145
+ >
146
+ All
147
+ </button>
148
+ {availableTypes.map((t) => (
149
+ <button
150
+ key={t}
151
+ type="button"
152
+ role="radio"
153
+ aria-checked={filterType === t}
154
+ onClick={() => setFilterType(t)}
155
+ className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
156
+ filterType === t
157
+ ? 'bg-[var(--lokvis-primary)]/15 text-[var(--lokvis-primary)]'
158
+ : 'bg-[var(--lokvis-surface-muted)] text-[var(--lokvis-fg-muted)] hover:bg-[var(--lokvis-border)]'
159
+ }`}
160
+ >
161
+ {TYPE_LABEL[t]}
162
+ </button>
163
+ ))}
164
+ </div>
165
+ {/* 搜索框:m4 文案改为与实际过滤行为一致(按 format/mimeType) */}
166
+ <Input
167
+ size="sm"
168
+ value={searchQuery}
169
+ onChange={(e) => setSearchQuery(e.target.value)}
170
+ placeholder="Filter by type..."
171
+ aria-label="Filter assets by format"
172
+ className="bg-[var(--lokvis-surface-muted)] hover:bg-[var(--lokvis-border)]/50"
173
+ leadingIcon={
174
+ <Icon size={11}>
175
+ <circle cx="10" cy="10" r="6" />
176
+ <path d="m20 20-5-5" />
177
+ </Icon>
178
+ }
179
+ />
180
+ </div>
181
+ )}
222
182
 
223
- {/* Asset list */}
224
- <div className="flex-1 overflow-y-auto px-2 pb-2">
225
- {assets.length === 0 ? (
226
- <p className="px-1 py-4 text-center text-[10px] text-zinc-400">
227
- No assets imported
228
- </p>
229
- ) : filteredAssets.length === 0 ? (
230
- <p className="px-1 py-4 text-center text-[10px] text-zinc-400">
231
- No assets match filter
232
- </p>
233
- ) : (
234
- <ul className="space-y-1">
235
- {filteredAssets.map((asset) => {
236
- const selected = asset.id === selectedAssetId;
237
- const thumb = thumbnails[asset.id];
238
- return (
239
- <li
240
- key={asset.id}
241
- // M3: 键盘可达 —— tabIndex + role + onKeyDown(Enter/Space 选中)
242
- tabIndex={0}
243
- role="button"
244
- aria-pressed={selected}
245
- aria-label={`Select asset ${asset.metadata.format}`}
246
- onClick={() => selectAsset(asset.id)}
247
- onKeyDown={(e) => {
248
- if (e.key === 'Enter' || e.key === ' ') {
249
- e.preventDefault();
250
- selectAsset(asset.id);
251
- }
252
- }}
253
- className={`group flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-all focus:outline-none focus:ring-2 focus:ring-indigo-300 ${
254
- selected
255
- ? 'bg-indigo-50 ring-1 ring-indigo-200 dark:bg-indigo-950/30 dark:ring-indigo-800'
256
- : 'hover:bg-zinc-50 dark:hover:bg-zinc-800'
257
- }`}
258
- >
259
- <div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded bg-zinc-100 dark:bg-zinc-800">
260
- {thumb ? (
261
- <img src={thumb} alt="" className="h-full w-full object-cover" />
262
- ) : (
263
- <span className="text-[10px] font-medium text-zinc-400">{asset.metadata.format.slice(0, 3).toUpperCase()}</span>
264
- )}
265
- </div>
266
- <div className="min-w-0 flex-1">
267
- <div className="truncate text-[11px] font-medium">
268
- {asset.metadata.format}
269
- </div>
270
- <div className="text-[10px] text-zinc-400">
271
- {(asset.metadata.size / 1024).toFixed(1)} KB
272
- </div>
273
- </div>
274
- <button
275
- type="button"
276
- onClick={(e) => {
277
- e.stopPropagation();
278
- void removeAsset(asset.id);
279
- }}
280
- className="shrink-0 rounded p-0.5 text-zinc-300 opacity-0 transition-all hover:bg-red-50 hover:text-red-500 focus:opacity-100 group-hover:opacity-100 dark:text-zinc-600 dark:hover:bg-red-950/30"
281
- aria-label={`Remove asset ${asset.metadata.format}`}
282
- >
283
- <Icon size={12}><path d="M6 18L18 6M6 6l12 12" /></Icon>
284
- </button>
285
- </li>
286
- );
287
- })}
288
- </ul>
289
- )}
290
- </div>
291
- </aside>
292
- );
183
+ {/* Asset list */}
184
+ <div className="flex-1 overflow-y-auto px-2 pb-2">
185
+ {assets.length === 0 ? (
186
+ <p className="px-1 py-4 text-center text-[10px] text-[var(--lokvis-fg-subtle)]">
187
+ No assets imported
188
+ </p>
189
+ ) : filteredAssets.length === 0 ? (
190
+ <p className="px-1 py-4 text-center text-[10px] text-[var(--lokvis-fg-subtle)]">
191
+ No assets match filter
192
+ </p>
193
+ ) : (
194
+ <ul className="space-y-1">
195
+ {filteredAssets.map((asset) => {
196
+ const selected = asset.id === selectedAssetId;
197
+ const thumb = thumbnails[asset.id];
198
+ return (
199
+ <li
200
+ key={asset.id}
201
+ // M3: 键盘可达 —— tabIndex + role + onKeyDown(Enter/Space 选中)
202
+ tabIndex={0}
203
+ role="button"
204
+ aria-pressed={selected}
205
+ aria-label={`Select asset ${asset.metadata.format}`}
206
+ onClick={() => selectAsset(asset.id)}
207
+ onKeyDown={(e) => {
208
+ if (e.key === 'Enter' || e.key === ' ') {
209
+ e.preventDefault();
210
+ selectAsset(asset.id);
211
+ }
212
+ }}
213
+ className={`group flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-all ${FOCUS_RING} ${
214
+ selected
215
+ ? 'bg-[var(--lokvis-primary)]/10 ring-1 ring-[var(--lokvis-primary)]/40'
216
+ : 'hover:bg-[var(--lokvis-surface)]'
217
+ }`}
218
+ >
219
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded bg-[var(--lokvis-surface-muted)]">
220
+ {thumb ? (
221
+ <img src={thumb} alt="" className="h-full w-full object-cover" />
222
+ ) : (
223
+ <span className="text-[10px] font-medium text-[var(--lokvis-fg-subtle)]">{asset.metadata.format.slice(0, 3).toUpperCase()}</span>
224
+ )}
225
+ </div>
226
+ <div className="min-w-0 flex-1">
227
+ <div className="truncate text-[11px] font-medium">
228
+ {asset.metadata.format}
229
+ </div>
230
+ <div className="text-[10px] text-[var(--lokvis-fg-subtle)]">
231
+ {(asset.metadata.size / 1024).toFixed(1)} KB
232
+ </div>
233
+ </div>
234
+ <button
235
+ type="button"
236
+ onClick={(e) => {
237
+ e.stopPropagation();
238
+ void removeAsset(asset.id);
239
+ }}
240
+ className="shrink-0 rounded p-0.5 text-[var(--lokvis-fg-subtle)] opacity-0 transition-all hover:bg-[var(--lokvis-danger)]/10 hover:text-[var(--lokvis-danger)] focus:opacity-100 group-hover:opacity-100"
241
+ aria-label={`Remove asset ${asset.metadata.format}`}
242
+ >
243
+ <Icon size={12}><path d="M6 18L18 6M6 6l12 12" /></Icon>
244
+ </button>
245
+ </li>
246
+ );
247
+ })}
248
+ </ul>
249
+ )}
250
+ </div>
251
+ </aside>
252
+ );
293
253
  }