@lokvis/ui-react 0.2.1 → 0.2.2

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 (97) hide show
  1. package/dist/components/AssetPanel.d.ts.map +1 -1
  2. package/dist/components/AssetPanel.js +14 -14
  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 +8 -8
  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 +6 -6
  13. package/dist/components/CompareSlider.d.ts.map +1 -1
  14. package/dist/components/CompareSlider.js +7 -7
  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 +9 -9
  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/ExifPanel.d.ts +1 -1
  25. package/dist/components/ExifPanel.d.ts.map +1 -1
  26. package/dist/components/ExifPanel.js +2 -2
  27. package/dist/components/ExifPanel.js.map +1 -1
  28. package/dist/components/GlobalDropzone.d.ts +5 -5
  29. package/dist/components/GlobalDropzone.d.ts.map +1 -1
  30. package/dist/components/GlobalDropzone.js +6 -6
  31. package/dist/components/GlobalDropzone.js.map +1 -1
  32. package/dist/components/HistoryPanel.d.ts +2 -2
  33. package/dist/components/HistoryPanel.d.ts.map +1 -1
  34. package/dist/components/HistoryPanel.js +23 -23
  35. package/dist/components/HistoryPanel.js.map +1 -1
  36. package/dist/components/Inspector.d.ts.map +1 -1
  37. package/dist/components/Inspector.js +6 -6
  38. package/dist/components/Inspector.js.map +1 -1
  39. package/dist/components/ParamForm.d.ts.map +1 -1
  40. package/dist/components/ParamForm.js +7 -7
  41. package/dist/components/ParamForm.js.map +1 -1
  42. package/dist/components/PipelineBar.d.ts.map +1 -1
  43. package/dist/components/PipelineBar.js +13 -13
  44. package/dist/components/PipelineBar.js.map +1 -1
  45. package/dist/components/ProgressBar.d.ts +4 -4
  46. package/dist/components/ProgressBar.d.ts.map +1 -1
  47. package/dist/components/ProgressBar.js +8 -8
  48. package/dist/components/ProgressBar.js.map +1 -1
  49. package/dist/components/StatusBar.d.ts +3 -3
  50. package/dist/components/StatusBar.d.ts.map +1 -1
  51. package/dist/components/StatusBar.js +11 -11
  52. package/dist/components/StatusBar.js.map +1 -1
  53. package/dist/components/ThemeToggle.d.ts +4 -4
  54. package/dist/components/ThemeToggle.d.ts.map +1 -1
  55. package/dist/components/ThemeToggle.js +7 -7
  56. package/dist/components/ThemeToggle.js.map +1 -1
  57. package/dist/components/Toolbar.d.ts.map +1 -1
  58. package/dist/components/Toolbar.js +2 -2
  59. package/dist/components/Toolbar.js.map +1 -1
  60. package/dist/components/WorkflowEditor.d.ts +6 -6
  61. package/dist/components/WorkflowEditor.d.ts.map +1 -1
  62. package/dist/components/WorkflowEditor.js +25 -25
  63. package/dist/components/WorkflowEditor.js.map +1 -1
  64. package/dist/components/WorkflowTemplates.d.ts +2 -2
  65. package/dist/components/WorkflowTemplates.d.ts.map +1 -1
  66. package/dist/components/WorkflowTemplates.js +3 -3
  67. package/dist/components/WorkflowTemplates.js.map +1 -1
  68. package/dist/components/Workspace.d.ts +22 -22
  69. package/dist/components/Workspace.d.ts.map +1 -1
  70. package/dist/components/Workspace.js +30 -30
  71. package/dist/components/Workspace.js.map +1 -1
  72. package/dist/index.d.ts +1 -0
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +3 -0
  75. package/dist/index.js.map +1 -1
  76. package/package.json +6 -6
  77. package/src/components/AssetPanel.tsx +263 -263
  78. package/src/components/Canvas.tsx +174 -174
  79. package/src/components/CommandPalette.tsx +242 -242
  80. package/src/components/CompareSlider.tsx +163 -163
  81. package/src/components/DownloadPanel.tsx +203 -203
  82. package/src/components/ErrorBanner.tsx +64 -64
  83. package/src/components/ExifPanel.tsx +154 -154
  84. package/src/components/GlobalDropzone.tsx +170 -170
  85. package/src/components/HistoryPanel.tsx +219 -219
  86. package/src/components/Inspector.tsx +136 -136
  87. package/src/components/ParamForm.tsx +136 -136
  88. package/src/components/PipelineBar.tsx +98 -98
  89. package/src/components/ProgressBar.tsx +78 -78
  90. package/src/components/StatusBar.tsx +148 -148
  91. package/src/components/ThemeToggle.tsx +98 -98
  92. package/src/components/Toolbar.tsx +65 -65
  93. package/src/components/WorkflowEditor.tsx +321 -321
  94. package/src/components/WorkflowTemplates.tsx +77 -77
  95. package/src/components/Workspace.tsx +223 -223
  96. package/src/css-modules.d.ts +3 -0
  97. package/src/index.ts +4 -0
@@ -11,283 +11,283 @@ import { Icon } 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 setThumbnail = useWorkspaceStore((s) => s.setThumbnail);
36
+ const runtime = useWorkspaceStore((s) => s.runtime);
37
37
 
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());
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());
44
44
 
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
- }
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
+ }
50
50
 
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]);
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]);
90
90
 
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]);
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]);
97
97
 
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]);
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]);
114
114
 
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>
115
+ return (
116
+ <aside
117
+ className={`flex w-56 shrink-0 flex-col border-r border-[var(--lokvis-border)] ${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-[var(--lokvis-border)]">
131
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--lokvis-fg-subtle)]">
132
+ Assets
133
+ </span>
134
+ <span className="text-[11px] tabular-nums text-[var(--lokvis-fg-subtle)]">
135
+ {filterType === 'all' && !searchQuery
136
+ ? assets.length
137
+ : `${filteredAssets.length}/${assets.length}`}
138
+ </span>
139
+ </div>
140
140
 
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>
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-[var(--lokvis-primary)] bg-[var(--lokvis-primary)]/10 text-[var(--lokvis-primary)]'
146
+ : 'border-[var(--lokvis-border)] text-[var(--lokvis-fg-subtle)] hover:border-[var(--lokvis-primary)]/50 hover:bg-[var(--lokvis-primary)]/10/50 hover:text-[var(--lokvis-primary)]'
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>
162
162
 
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
- )}
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-[var(--lokvis-primary)]/15 text-[var(--lokvis-primary)]'
180
+ : 'bg-[var(--lokvis-surface-muted)] text-[var(--lokvis-fg-muted)] hover:bg-[var(--lokvis-border)]'
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-[var(--lokvis-primary)]/15 text-[var(--lokvis-primary)]'
195
+ : 'bg-[var(--lokvis-surface-muted)] text-[var(--lokvis-fg-muted)] hover:bg-[var(--lokvis-border)]'
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-[var(--lokvis-fg-subtle)] 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-[var(--lokvis-surface-muted)] py-1 pl-6 pr-1.5 text-[10px] text-[var(--lokvis-fg-muted)] placeholder:text-[var(--lokvis-fg-subtle)] focus:outline-none focus:ring-1 focus:ring-[var(--lokvis-primary)]/50"
218
+ />
219
+ </div>
220
+ </div>
221
+ )}
222
222
 
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
- );
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-[var(--lokvis-fg-subtle)]">
227
+ No assets imported
228
+ </p>
229
+ ) : filteredAssets.length === 0 ? (
230
+ <p className="px-1 py-4 text-center text-[10px] text-[var(--lokvis-fg-subtle)]">
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-[var(--lokvis-primary)]/50 ${
254
+ selected
255
+ ? 'bg-[var(--lokvis-primary)]/10 ring-1 ring-[var(--lokvis-primary)]/40'
256
+ : 'hover:bg-[var(--lokvis-surface)]'
257
+ }`}
258
+ >
259
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded bg-[var(--lokvis-surface-muted)]">
260
+ {thumb ? (
261
+ <img src={thumb} alt="" className="h-full w-full object-cover" />
262
+ ) : (
263
+ <span className="text-[10px] font-medium text-[var(--lokvis-fg-subtle)]">{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-[var(--lokvis-fg-subtle)]">
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-[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"
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
+ );
293
293
  }