most-box 0.0.1 → 0.0.4

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 (71) hide show
  1. package/README.md +182 -73
  2. package/out/404/index.html +15 -0
  3. package/out/404.html +15 -0
  4. package/out/__next.__PAGE__.txt +9 -0
  5. package/out/__next._full.txt +18 -0
  6. package/out/__next._head.txt +5 -0
  7. package/out/__next._index.txt +6 -0
  8. package/out/__next._tree.txt +2 -0
  9. package/out/_next/static/0h4f4QFk_KC9FlSRfQACk/_buildManifest.js +11 -0
  10. package/out/_next/static/0h4f4QFk_KC9FlSRfQACk/_clientMiddlewareManifest.js +1 -0
  11. package/out/_next/static/0h4f4QFk_KC9FlSRfQACk/_ssgManifest.js +1 -0
  12. package/out/_next/static/chunks/00l-yd3t8dvwz.js +5 -0
  13. package/out/_next/static/chunks/03k8t3tgym~8~.js +1 -0
  14. package/out/_next/static/chunks/03~yq9q893hmn.js +1 -0
  15. package/out/_next/static/chunks/09vfh8lfuacc0.css +1 -0
  16. package/out/_next/static/chunks/0bogtdbh.dcu1.js +1 -0
  17. package/out/_next/static/chunks/0dbhjjzl8qfwv.js +1 -0
  18. package/out/_next/static/chunks/0f73psqhr8dre.css +1 -0
  19. package/out/_next/static/chunks/0fbi7z4_.4j1j.js +1 -0
  20. package/out/_next/static/chunks/0ht900cau6_ur.js +31 -0
  21. package/out/_next/static/chunks/0ohm.ia-4ec60.js +1 -0
  22. package/out/_next/static/chunks/0u5ydb-f0.vxl.js +1 -0
  23. package/out/_next/static/chunks/14t2m1on-s5v~.js +1 -0
  24. package/out/_next/static/chunks/turbopack-076ce9exut_h3.js +1 -0
  25. package/out/_not-found/__next._full.txt +16 -0
  26. package/out/_not-found/__next._head.txt +5 -0
  27. package/out/_not-found/__next._index.txt +6 -0
  28. package/out/_not-found/__next._not-found/__PAGE__.txt +5 -0
  29. package/out/_not-found/__next._not-found.txt +5 -0
  30. package/out/_not-found/__next._tree.txt +2 -0
  31. package/out/_not-found/index.html +15 -0
  32. package/out/_not-found/index.txt +16 -0
  33. package/out/app.css +1535 -0
  34. package/out/bundle.js +107 -0
  35. package/out/bundle.js.map +7 -0
  36. package/out/chat/__next._full.txt +19 -0
  37. package/out/chat/__next._head.txt +5 -0
  38. package/out/chat/__next._index.txt +6 -0
  39. package/out/chat/__next._tree.txt +3 -0
  40. package/out/chat/__next.chat/__PAGE__.txt +9 -0
  41. package/out/chat/__next.chat.txt +5 -0
  42. package/out/chat/index.html +15 -0
  43. package/out/chat/index.txt +19 -0
  44. package/out/chat-page.js +112 -0
  45. package/out/chat.css +378 -0
  46. package/out/favicon.ico +0 -0
  47. package/out/index.html +15 -0
  48. package/out/index.js +148 -0
  49. package/out/index.txt +18 -0
  50. package/package.json +16 -7
  51. package/public/app.css +1535 -0
  52. package/public/bundle.js +10 -14
  53. package/public/bundle.js.map +4 -4
  54. package/public/chat-page.js +112 -0
  55. package/public/chat.css +378 -0
  56. package/public/index.js +148 -0
  57. package/server.js +464 -199
  58. package/src/config.js +36 -8
  59. package/src/core/cid.js +28 -19
  60. package/src/index.js +872 -276
  61. package/src/utils/api.js +6 -0
  62. package/src/utils/security.js +27 -24
  63. package/build.mjs +0 -40
  64. package/public/app.jsx +0 -1335
  65. package/public/icons/apple-touch-icon.png +0 -0
  66. package/public/icons/mask-icon.svg +0 -3
  67. package/public/icons/most.png +0 -0
  68. package/public/icons/pwa-192x192.png +0 -0
  69. package/public/icons/pwa-512x512.png +0 -0
  70. package/public/index.html +0 -15
  71. package/public/index.jsx +0 -5
package/public/app.jsx DELETED
@@ -1,1335 +0,0 @@
1
- import React, { useState, useEffect, useRef } from 'react'
2
- import {
3
- Upload, Sun, Moon, Image as ImageIcon, Trash2, Folder,
4
- FolderPlus, Film, Music, ChevronRight, FileText,
5
- X, Check, Copy, Download, ArrowUpDown, Star, Files, HardDrive, Search, Info,
6
- FolderOpen, Power
7
- } from 'lucide-react'
8
-
9
- // === API ===
10
- const API = {
11
- async fetch(url, options = {}) {
12
- const res = await fetch(url, options)
13
- if (!res.ok) {
14
- const err = await res.json().catch(() => ({ error: res.statusText }))
15
- throw new Error(err.error || 'Request failed')
16
- }
17
- return res.json()
18
- },
19
- listPublishedFiles: () => API.fetch('/api/files'),
20
- listTrashFiles: () => API.fetch('/api/trash'),
21
- deletePublishedFile: (cid) => API.fetch(`/api/files/${cid}`, { method: 'DELETE' }),
22
- restoreTrashFile: (cid) => API.fetch(`/api/trash/${cid}/restore`, { method: 'POST' }),
23
- permanentDeleteTrashFile: (cid) => API.fetch(`/api/trash/${cid}`, { method: 'DELETE' }),
24
- emptyTrash: () => API.fetch('/api/trash', { method: 'DELETE' }),
25
- toggleStar: (cid) => API.fetch(`/api/files/${cid}/star`, { method: 'POST' }),
26
- getStorageStats: () => API.fetch('/api/storage'),
27
- getConfig: () => API.fetch('/api/config'),
28
- getDataPath: () => API.fetch('/api/config/data-path'),
29
- saveConfig: (config) => API.fetch('/api/config', {
30
- method: 'POST',
31
- headers: { 'Content-Type': 'application/json' },
32
- body: JSON.stringify(config)
33
- }),
34
- async publishFile(file, customName) {
35
- const formData = new FormData()
36
- formData.append('file', file, customName || file.name)
37
- const res = await fetch('/api/publish', { method: 'POST', body: formData })
38
- if (!res.ok) {
39
- const err = await res.json().catch(() => ({ error: res.statusText }))
40
- throw new Error(err.error || 'Request failed')
41
- }
42
- return res.json()
43
- },
44
- downloadFile: (link) => API.fetch('/api/download', {
45
- method: 'POST',
46
- headers: { 'Content-Type': 'application/json' },
47
- body: JSON.stringify({ link })
48
- }),
49
- cancelDownload: (taskId) => API.fetch('/api/download/cancel', {
50
- method: 'POST',
51
- headers: { 'Content-Type': 'application/json' },
52
- body: JSON.stringify({ taskId })
53
- }),
54
- getFileDownloadUrl: (cid) => `/api/files/${cid}/download`,
55
- moveFile: (cid, newFileName) => API.fetch('/api/move', {
56
- method: 'POST',
57
- headers: { 'Content-Type': 'application/json' },
58
- body: JSON.stringify({ cid, newFileName })
59
- }),
60
- renameFolder: (oldPath, newPath) => API.fetch('/api/folder/rename', {
61
- method: 'POST',
62
- headers: { 'Content-Type': 'application/json' },
63
- body: JSON.stringify({ oldPath, newPath })
64
- })
65
- }
66
-
67
- // === Helpers ===
68
- function formatSize(bytes) {
69
- if (!bytes || bytes <= 0) return '0 B'
70
- if (bytes < 1024) return `${bytes} B`
71
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
72
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
73
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
74
- }
75
-
76
- function formatDate(dateString) {
77
- if (!dateString) return ''
78
- return new Date(dateString).toLocaleDateString('zh-CN')
79
- }
80
-
81
- function parseName(fullPath) {
82
- const lastSlash = fullPath.lastIndexOf('/')
83
- if (lastSlash === -1) return { folder: '', name: fullPath }
84
- return { folder: fullPath.substring(0, lastSlash), name: fullPath.substring(lastSlash + 1) }
85
- }
86
-
87
- function getUniqueFolders(files) {
88
- const folders = new Set()
89
- files.forEach(f => {
90
- const { folder } = parseName(f.fileName)
91
- let parts = folder.split('/').filter(Boolean)
92
- let acc = ''
93
- for (const part of parts) {
94
- acc += (acc ? '/' : '') + part
95
- folders.add(acc)
96
- }
97
- })
98
- return [...folders].sort()
99
- }
100
-
101
- function getCurrentFolders(allFolders, currentPath) {
102
- const prefix = currentPath ? currentPath + '/' : ''
103
- return allFolders.filter(f => {
104
- const isUnder = f.toLowerCase().startsWith(prefix.toLowerCase())
105
- const remainder = f.substring(prefix.length)
106
- return isUnder && !remainder.includes('/')
107
- }).map(f => ({ name: f.substring(prefix.length), path: f }))
108
- }
109
-
110
- function getItemsForPath(files, allFolders, currentPath) {
111
- return {
112
- folders: getCurrentFolders(allFolders, currentPath),
113
- files: files.filter(f => parseName(f.fileName).folder === currentPath)
114
- }
115
- }
116
-
117
- function getFileSubtype(fileName) {
118
- const ext = fileName.split('.').pop().toLowerCase()
119
- const imgExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'heic', 'heif']
120
- const vidExts = ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'm4v', 'mpeg', '3gp']
121
- const audExts = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'opus']
122
- if (imgExts.includes(ext)) return 'image'
123
- if (vidExts.includes(ext)) return 'video'
124
- if (audExts.includes(ext)) return 'audio'
125
- return 'file'
126
- }
127
-
128
- // === Welcome Guide ===
129
- function WelcomeGuide({ onClose }) {
130
- const [step, setStep] = useState(0)
131
- const steps = [
132
- { title: '欢迎使用', content: '拖拽文件到上传区,或点击选择文件。上传后复制链接发给朋友即可。' },
133
- { title: '下载文件', content: '点击「下载文件」,粘贴分享链接即可从 P2P 网络下载文件。' }
134
- ]
135
- const current = steps[step]
136
-
137
- return (
138
- <ModalOverlay onClose={onClose}>
139
- <div style={{ width: 360, padding: 28, borderRadius: 16, background: '#fff', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
140
- <h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 12 }}>{current.title}</h2>
141
- <p style={{ fontSize: 13, color: '#64748b', lineHeight: 1.6, marginBottom: 20 }}>{current.content}</p>
142
- <div style={{ display: 'flex', justifyContent: 'center', gap: 6, marginBottom: 20 }}>
143
- {steps.map((_, i) => (
144
- <div key={i} style={{ width: 6, height: 6, borderRadius: '50%', background: i === step ? '#3b82f6' : '#e2e8f0' }} />
145
- ))}
146
- </div>
147
- <button onClick={step === steps.length - 1 ? onClose : () => setStep(step + 1)} style={{ padding: '10px 32px', borderRadius: 10, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 14 }}>
148
- {step === steps.length - 1 ? '开始使用' : '下一步'}
149
- </button>
150
- </div>
151
- </ModalOverlay>
152
- )
153
- }
154
-
155
- // === About Modal ===
156
- function SettingsModal({ onClose, addToast }) {
157
- const [dataPath, setStoragePath] = useState('')
158
- const [originalPath, setOriginalPath] = useState('')
159
- const [isDefault, setIsDefault] = useState(false)
160
- const [loading, setLoading] = useState(true)
161
- const [saving, setSaving] = useState(false)
162
-
163
- useEffect(() => {
164
- API.getDataPath().then(config => {
165
- const path = config.dataPath || ''
166
- setStoragePath(path)
167
- setOriginalPath(path)
168
- setIsDefault(config.isDefault || false)
169
- setLoading(false)
170
- }).catch(() => setLoading(false))
171
- }, [])
172
-
173
- const handleSavePath = async () => {
174
- if (!dataPath.trim()) return
175
- if (dataPath.trim() === originalPath) return
176
- setSaving(true)
177
- try {
178
- await API.saveConfig({ dataPath: dataPath.trim() })
179
- await fetch('/api/shutdown', { method: 'POST' })
180
- window.close()
181
- } catch (err) {
182
- addToast(err.message || '保存失败', 'error')
183
- setSaving(false)
184
- }
185
- }
186
-
187
- const handleResetPath = async () => {
188
- if (originalPath === '') return
189
- setSaving(true)
190
- try {
191
- await API.saveConfig({ resetStorage: true })
192
- await fetch('/api/shutdown', { method: 'POST' })
193
- window.close()
194
- } catch (err) {
195
- addToast(err.message || '操作失败', 'error')
196
- setSaving(false)
197
- }
198
- }
199
-
200
- const isPathChanged = dataPath.trim() !== originalPath
201
-
202
- return (
203
- <ModalOverlay onClose={onClose}>
204
- <div style={{ width: 420, padding: 28, borderRadius: 16, background: '#fff' }} onClick={e => e.stopPropagation()}>
205
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
206
- <h2 style={{ fontSize: 18, fontWeight: 600 }}>设置</h2>
207
- <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8' }}><X size={18} /></button>
208
- </div>
209
-
210
- <div style={{ marginBottom: 20 }}>
211
- <label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 8, color: '#374151' }}>存储位置</label>
212
- <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
213
- <input
214
- type="text"
215
- value={dataPath}
216
- onChange={(e) => setStoragePath(e.target.value)}
217
- placeholder="输入完整路径,如 D:\most-data"
218
- disabled={loading}
219
- style={{ flex: 1, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #e5e7eb', fontSize: 13, outline: 'none' }}
220
- />
221
- <button onClick={handleSavePath} disabled={saving || loading || !isPathChanged} style={{ padding: '10px 16px', borderRadius: 8, border: 'none', background: '#3b82f6', color: '#fff', cursor: saving || loading || !isPathChanged ? 'not-allowed' : 'pointer', fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', opacity: saving || loading || !isPathChanged ? 0.5 : 1 }}>
222
- {saving ? '保存中...' : '保存'}
223
- </button>
224
- {!isDefault && (
225
- <button onClick={handleResetPath} disabled={saving || loading} style={{ padding: '10px 12px', borderRadius: 8, border: '1px solid #e5e7eb', background: '#fff', color: '#6b7280', cursor: saving || loading ? 'not-allowed' : 'pointer', fontSize: 13, whiteSpace: 'nowrap', opacity: saving || loading ? 0.5 : 1 }}>
226
- 恢复默认
227
- </button>
228
- )}
229
- </div>
230
- <p style={{ fontSize: 11, color: '#9ca3af', marginTop: 8 }}>重启后生效</p>
231
- </div>
232
-
233
- <div style={{ borderTop: '1px solid #e5e7eb', paddingTop: 16 }}>
234
- <div style={{ textAlign: 'center', marginBottom: 16 }}>
235
- <h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>MostBox</h3>
236
- <p style={{ fontSize: 12, color: '#9ca3af' }}>版本 0.0.1</p>
237
- </div>
238
- <p style={{ fontSize: 12, color: '#6b7280', textAlign: 'center' }}>Hyperswarm · Hyperdrive · IPFS</p>
239
- </div>
240
-
241
- <button onClick={onClose} style={{ width: '100%', marginTop: 20, padding: 10, borderRadius: 10, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 14 }}>
242
- 关闭
243
- </button>
244
- </div>
245
- </ModalOverlay>
246
- )
247
- }
248
-
249
- // === Toast ===
250
- const TOAST_COLORS = { success: '#22c55e', error: '#ef4444', warning: '#f59e0b', info: '#3b82f6' }
251
-
252
- function Toast({ message, type, onDone, index }) {
253
- useEffect(() => {
254
- const t = setTimeout(onDone, 3000)
255
- return () => clearTimeout(t)
256
- }, [])
257
- return (
258
- <div style={{
259
- position: 'fixed', bottom: 24 + index * 60, right: 24, zIndex: 9999,
260
- background: TOAST_COLORS[type] || TOAST_COLORS.info, color: '#fff',
261
- padding: '12px 20px', borderRadius: 12, fontSize: 13, fontWeight: 500,
262
- boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
263
- animation: 'toastSlideIn 0.2s ease'
264
- }}>
265
- {message}
266
- </div>
267
- )
268
- }
269
-
270
- // === Modal Overlay ===
271
- function ModalOverlay({ children, onClose }) {
272
- return (
273
- <div style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
274
- {children}
275
- </div>
276
- )
277
- }
278
-
279
- // === Shared Styles ===
280
- const iconContainerStyle = {
281
- width: 56, height: 56, borderRadius: 12, marginBottom: 10,
282
- display: 'flex', alignItems: 'center', justifyContent: 'center'
283
- }
284
-
285
- const textEllipsisStyle = {
286
- fontSize: 12, fontWeight: 500, textAlign: 'center', maxWidth: '100%',
287
- overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
288
- }
289
-
290
- // === Breadcrumb Generator ===
291
- function generateBreadcrumbs(currentPath) {
292
- if (!currentPath) return []
293
- return [
294
- { path: '', name: '全部内容' },
295
- ...currentPath.split('/').filter(Boolean).map((part, i, arr) => ({
296
- path: arr.slice(0, i + 1).join('/'),
297
- name: part
298
- }))
299
- ]
300
- }
301
-
302
- // === Refresh Handler Factory ===
303
- const createRefreshHandler = (setter, apiMethod) => async () => {
304
- try { setter(await apiMethod()) }
305
- catch (err) { console.error(err) }
306
- }
307
-
308
- // === File Card ===
309
- function FileCard({ file, isSelected, isDarkMode, onSelect, onPreview }) {
310
- const bgSecondary = isDarkMode ? '#1e293b' : '#ffffff'
311
- const accentBlue = isDarkMode ? '#60a5fa' : '#3b82f6'
312
- const borderColor = isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'
313
- const textColor = isDarkMode ? '#e5e7eb' : '#111827'
314
- const subtype = getFileSubtype(file.fileName)
315
-
316
- return (
317
- <div
318
- data-id={file.cid}
319
- onClick={() => onSelect(file.cid)}
320
- onDoubleClick={() => onPreview(file)}
321
- style={{
322
- display: 'flex', flexDirection: 'column', alignItems: 'center',
323
- padding: 16, borderRadius: 12, cursor: 'pointer',
324
- background: isSelected ? accentBlue + '15' : bgSecondary,
325
- border: `1px solid ${isSelected ? accentBlue : borderColor}`,
326
- transition: 'all 0.15s'
327
- }}
328
- >
329
- <div style={{
330
- ...iconContainerStyle,
331
- background: file.starred ? 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%)' : 'linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%)'
332
- }}>
333
- {subtype === 'image' && <ImageIcon size={24} color="#fff" />}
334
- {subtype === 'video' && <Film size={24} color="#fff" />}
335
- {subtype === 'audio' && <Music size={24} color="#fff" />}
336
- {subtype === 'file' && <FileText size={24} color="#fff" />}
337
- </div>
338
- <p style={{ ...textEllipsisStyle, color: textColor }}>
339
- {parseName(file.fileName).name}
340
- </p>
341
- </div>
342
- )
343
- }
344
-
345
- // === Folder Card ===
346
- function FolderCard({ folder, isDarkMode, onClick }) {
347
- const bgSecondary = isDarkMode ? '#1e293b' : '#ffffff'
348
- const borderColor = isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'
349
- const textColor = isDarkMode ? '#e5e7eb' : '#111827'
350
-
351
- return (
352
- <div
353
- onClick={onClick}
354
- style={{
355
- display: 'flex', flexDirection: 'column', alignItems: 'center',
356
- padding: 16, borderRadius: 12, cursor: 'pointer',
357
- background: bgSecondary,
358
- border: `1px solid ${borderColor}`,
359
- transition: 'all 0.15s'
360
- }}
361
- >
362
- <div style={{ ...iconContainerStyle, background: 'linear-gradient(135deg, #818cf8 0%, #6366f1 100%)' }}>
363
- <Folder size={28} color="#fff" />
364
- </div>
365
- <p style={{ ...textEllipsisStyle, color: textColor }}>
366
- {folder.name}
367
- </p>
368
- </div>
369
- )
370
- }
371
-
372
- // === Confirm Modal ===
373
- function ConfirmModal({ title, message, confirmText, onConfirm, onClose, danger }) {
374
- const isDarkMode = false
375
- const bgSecondary = '#ffffff'
376
- const bgTertiary = '#f1f5f9'
377
- const textPrimary = '#0f172a'
378
- const textSecondary = '#64748b'
379
- const borderColor = 'rgba(0,0,0,0.06)'
380
- return (
381
- <ModalOverlay onClose={onClose}>
382
- <div style={{ width: 360, padding: 24, borderRadius: 16, background: bgSecondary, border: `1px solid ${borderColor}` }} onClick={e => e.stopPropagation()}>
383
- <h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>{title}</h3>
384
- <p style={{ fontSize: 13, color: textSecondary, marginBottom: 20 }}>{message}</p>
385
- <div style={{ display: 'flex', gap: 8 }}>
386
- <button onClick={onClose} style={{ flex: 1, padding: 10, borderRadius: 8, border: `1px solid ${borderColor}`, background: 'transparent', cursor: 'pointer', fontSize: 13 }}>取消</button>
387
- <button onClick={onConfirm} style={{ flex: 1, padding: 10, borderRadius: 8, border: 'none', background: danger ? '#ef4444' : '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 500 }}>{confirmText}</button>
388
- </div>
389
- </div>
390
- </ModalOverlay>
391
- )
392
- }
393
-
394
- // === Input Modal ===
395
- function InputModal({ title, placeholder, defaultValue, confirmText, onConfirm, onClose }) {
396
- const [value, setValue] = useState(defaultValue || '')
397
- const bgSecondary = '#ffffff'
398
- const bgTertiary = '#f1f5f9'
399
- const textPrimary = '#0f172a'
400
- const textSecondary = '#64748b'
401
- const borderColor = 'rgba(0,0,0,0.06)'
402
- return (
403
- <ModalOverlay onClose={onClose}>
404
- <div style={{ width: 360, padding: 24, borderRadius: 16, background: bgSecondary, border: `1px solid ${borderColor}` }} onClick={e => e.stopPropagation()}>
405
- <h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>{title}</h3>
406
- <input
407
- type="text"
408
- value={value}
409
- onChange={(e) => setValue(e.target.value)}
410
- placeholder={placeholder}
411
- autoFocus
412
- onKeyDown={(e) => { if (e.key === 'Enter' && value.trim()) onConfirm(value.trim()) }}
413
- style={{ width: '100%', padding: '10px 12px', borderRadius: 8, border: `1.5px solid ${borderColor}`, fontSize: 13, outline: 'none', background: bgTertiary, color: textPrimary, marginBottom: 16 }}
414
- />
415
- <div style={{ display: 'flex', gap: 8 }}>
416
- <button onClick={onClose} style={{ flex: 1, padding: 10, borderRadius: 8, border: `1px solid ${borderColor}`, background: 'transparent', cursor: 'pointer', fontSize: 13 }}>取消</button>
417
- <button onClick={() => value.trim() && onConfirm(value.trim())} disabled={!value.trim()} style={{ flex: 1, padding: 10, borderRadius: 8, border: 'none', background: value.trim() ? '#3b82f6' : bgTertiary, color: value.trim() ? '#fff' : textSecondary, cursor: value.trim() ? 'pointer' : 'not-allowed', fontSize: 13, fontWeight: 500 }}>{confirmText}</button>
418
- </div>
419
- </div>
420
- </ModalOverlay>
421
- )
422
- }
423
-
424
- // === Move Modal ===
425
- function MoveModal({ items, allFolders, currentPath, isDarkMode, onMove, onClose }) {
426
- const [targetPath, setTargetPath] = useState('')
427
- const bgSecondary = isDarkMode ? '#1e293b' : '#ffffff'
428
- const bgTertiary = isDarkMode ? '#334155' : '#f1f5f9'
429
- const textPrimary = isDarkMode ? '#f8fafc' : '#0f172a'
430
- const textSecondary = isDarkMode ? '#94a3b8' : '#64748b'
431
- const borderColor = isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'
432
- const accentBlue = isDarkMode ? '#60a5fa' : '#3b82f6'
433
-
434
- const breadcrumbParts = generateBreadcrumbs(targetPath)
435
-
436
- return (
437
- <ModalOverlay onClose={onClose}>
438
- <div style={{ width: 400, padding: 24, borderRadius: 16, background: bgSecondary, border: `1px solid ${borderColor}` }} onClick={e => e.stopPropagation()}>
439
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
440
- <h3 style={{ fontSize: 16, fontWeight: 600 }}>移动到</h3>
441
- <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: textSecondary }}><X size={18} /></button>
442
- </div>
443
- <p style={{ fontSize: 12, color: textSecondary, marginBottom: 12 }}>已选 {items.length} 个项目</p>
444
- <div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
445
- {breadcrumbParts.map((part, i) => (
446
- <React.Fragment key={part.path}>
447
- {i > 0 && <span style={{ color: textSecondary }}>/</span>}
448
- <button
449
- key={part.path}
450
- onClick={() => setTargetPath(part.path)}
451
- style={{
452
- padding: '4px 8px', borderRadius: 6, border: 'none',
453
- background: targetPath === part.path ? accentBlue + '20' : bgTertiary,
454
- color: targetPath === part.path ? accentBlue : textSecondary,
455
- cursor: 'pointer', fontSize: 12
456
- }}
457
- >
458
- {part.name}
459
- </button>
460
- </React.Fragment>
461
- ))}
462
- </div>
463
- <div style={{ maxHeight: 200, overflow: 'auto', marginBottom: 16 }}>
464
- {allFolders.filter(f => f.path.startsWith(targetPath + (targetPath ? '/' : ''))).length === 0 && targetPath !== '' && (
465
- <p style={{ fontSize: 12, color: textSecondary, textAlign: 'center', padding: 16 }}>该目录下没有子文件夹</p>
466
- )}
467
- {allFolders.filter(f => f.path.startsWith(targetPath + (targetPath ? '/' : ''))).map(folder => (
468
- <button
469
- key={folder.path}
470
- onClick={() => setTargetPath(folder.path)}
471
- style={{
472
- display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 12px',
473
- borderRadius: 8, border: 'none', background: 'transparent',
474
- cursor: 'pointer', color: textPrimary, fontSize: 13
475
- }}
476
- >
477
- <Folder size={16} color="#6366f1" />
478
- <span>{folder.name}</span>
479
- </button>
480
- ))}
481
- </div>
482
- <div style={{ display: 'flex', gap: 8 }}>
483
- <button onClick={onClose} style={{ flex: 1, padding: 10, borderRadius: 8, border: `1px solid ${borderColor}`, background: 'transparent', cursor: 'pointer', fontSize: 13 }}>取消</button>
484
- <button
485
- onClick={() => onMove(targetPath)}
486
- disabled={targetPath === currentPath}
487
- style={{
488
- flex: 1, padding: 10, borderRadius: 8, border: 'none',
489
- background: targetPath === currentPath ? bgTertiary : accentBlue,
490
- color: targetPath === currentPath ? textSecondary : '#fff',
491
- cursor: targetPath === currentPath ? 'not-allowed' : 'pointer',
492
- fontSize: 13, fontWeight: 500
493
- }}
494
- >
495
- 移动
496
- </button>
497
- </div>
498
- </div>
499
- </ModalOverlay>
500
- )
501
- }
502
-
503
- // === Main App ===
504
- export default function App() {
505
- const [items, setItems] = useState([])
506
- const [trashItems, setTrashItems] = useState([])
507
- const [currentFolderId, setCurrentFolderId] = useState(null)
508
- const [currentView, setCurrentView] = useState('all')
509
- const [isDarkMode, setIsDarkMode] = useState(false)
510
- const [isDraggingOverUpload, setIsDraggingOverUpload] = useState(false)
511
- const [selectedIds, setSelectedIds] = useState([])
512
- const [previewItem, setPreviewItem] = useState(null)
513
- const [shareItem, setShareItem] = useState(null)
514
- const [isDownloadModalOpen, setIsDownloadModalOpen] = useState(false)
515
- const [downloadLink, setDownloadLink] = useState('')
516
- const [toasts, setToasts] = useState([])
517
- const [transfers, setTransfers] = useState([])
518
- const [isTransferPanelOpen, setIsTransferPanelOpen] = useState(false)
519
- const [searchQuery, setSearchQuery] = useState('')
520
- const [copied, setCopied] = useState(false)
521
- const [peerCount, setPeerCount] = useState(0)
522
- const [storageStats, setStorageStats] = useState({ total: 0, used: 0, free: 0 })
523
- const [isMoveModalOpen, setIsMoveModalOpen] = useState(false)
524
- const [confirmModal, setConfirmModal] = useState(null)
525
- const [inputModal, setInputModal] = useState(null)
526
- const [renameTarget, setRenameTarget] = useState(null)
527
- const [showWelcome, setShowWelcome] = useState(() => !localStorage.getItem('mostbox_welcomed'))
528
- const [showSettings, setShowSettings] = useState(false)
529
-
530
- const currentPath = currentFolderId || ''
531
- const allFolders = getUniqueFolders(items)
532
- const { folders, files } = getItemsForPath(items, allFolders, currentPath)
533
-
534
- const filteredFiles = searchQuery
535
- ? items.filter(f => parseName(f.fileName).name.toLowerCase().includes(searchQuery.toLowerCase()))
536
- : files
537
-
538
- const addToast = (message, type = 'info') => setToasts(prev => [...prev, { id: Date.now(), message, type }])
539
- const removeToast = (id) => setToasts(prev => prev.filter(t => t.id !== id))
540
-
541
- const refreshFiles = createRefreshHandler(setItems, () => API.listPublishedFiles().then(r => r || []))
542
- const refreshTrash = createRefreshHandler(setTrashItems, () => API.listTrashFiles().then(r => r || []))
543
- const refreshStorageStats = createRefreshHandler(setStorageStats, API.getStorageStats)
544
-
545
- const handleSelect = (id) => {
546
- setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])
547
- }
548
-
549
- const handleDelete = async (id) => {
550
- setConfirmModal({
551
- title: '确认删除',
552
- message: '确定要删除吗?',
553
- confirmText: '删除',
554
- danger: true,
555
- onConfirm: async () => {
556
- setConfirmModal(null)
557
- try {
558
- await API.deletePublishedFile(id)
559
- setSelectedIds(prev => prev.filter(i => i !== id))
560
- addToast('已删除', 'success')
561
- refreshFiles()
562
- refreshTrash()
563
- refreshStorageStats()
564
- } catch { addToast('删除失败', 'error') }
565
- }
566
- })
567
- }
568
-
569
- const handleFolderDelete = async (folder) => {
570
- const toDelete = items.filter(i => parseName(i.fileName).folder.toLowerCase() === folder.path.toLowerCase())
571
- setConfirmModal({
572
- title: '确认删除',
573
- message: toDelete.length > 0 ? `确定要删除文件夹中的 ${toDelete.length} 个文件吗?` : '确定要删除此文件夹吗?',
574
- confirmText: '删除',
575
- danger: true,
576
- onConfirm: async () => {
577
- setConfirmModal(null)
578
- try {
579
- for (const f of toDelete) { if (f.cid) await API.deletePublishedFile(f.cid) }
580
- addToast('已删除', 'success')
581
- refreshFiles()
582
- refreshTrash()
583
- refreshStorageStats()
584
- } catch { addToast('删除失败', 'error') }
585
- }
586
- })
587
- }
588
-
589
- const handlePermanentDelete = async (cid) => {
590
- setConfirmModal({
591
- title: '永久删除',
592
- message: '确定要永久删除吗?此操作不可恢复!',
593
- confirmText: '永久删除',
594
- danger: true,
595
- onConfirm: async () => {
596
- setConfirmModal(null)
597
- try {
598
- await API.permanentDeleteTrashFile(cid)
599
- addToast('已永久删除', 'success')
600
- refreshTrash()
601
- refreshStorageStats()
602
- } catch { addToast('删除失败', 'error') }
603
- }
604
- })
605
- }
606
-
607
- const handleRestore = async (cid) => {
608
- try {
609
- await API.restoreTrashFile(cid)
610
- addToast('已恢复', 'success')
611
- refreshFiles()
612
- refreshTrash()
613
- refreshStorageStats()
614
- } catch { addToast('恢复失败', 'error') }
615
- }
616
-
617
- const handleEmptyTrash = async () => {
618
- setConfirmModal({
619
- title: '清空回收站',
620
- message: '确定要清空回收站吗?此操作不可恢复!',
621
- confirmText: '清空',
622
- danger: true,
623
- onConfirm: async () => {
624
- setConfirmModal(null)
625
- try {
626
- await API.emptyTrash()
627
- addToast('回收站已清空', 'success')
628
- refreshTrash()
629
- refreshStorageStats()
630
- } catch { addToast('清空失败', 'error') }
631
- }
632
- })
633
- }
634
-
635
- const handleToggleStar = async (cid) => {
636
- try {
637
- const result = await API.toggleStar(cid)
638
- setItems(prev => prev.map(i => i.cid === cid ? { ...i, starred: result.starred } : i))
639
- addToast(result.starred ? '已收藏' : '已取消收藏', 'success')
640
- } catch { addToast('操作失败', 'error') }
641
- }
642
-
643
- const handleBatchDelete = async () => {
644
- const isTrash = currentView === 'trash'
645
- setConfirmModal({
646
- title: isTrash ? '永久删除' : '批量删除',
647
- message: isTrash ? `确定要永久删除选中的 ${selectedIds.length} 个项目吗?此操作不可恢复!` : `确定要删除选中的 ${selectedIds.length} 个项目吗?`,
648
- confirmText: isTrash ? '永久删除' : '删除',
649
- danger: true,
650
- onConfirm: async () => {
651
- setConfirmModal(null)
652
- try {
653
- for (const id of selectedIds) {
654
- if (isTrash) {
655
- await API.permanentDeleteTrashFile(id)
656
- } else {
657
- if (!id.startsWith('__')) await API.deletePublishedFile(id)
658
- }
659
- }
660
- setSelectedIds([])
661
- addToast(isTrash ? '已永久删除' : '已删除', 'success')
662
- refreshFiles()
663
- refreshTrash()
664
- refreshStorageStats()
665
- } catch { addToast('删除失败', 'error') }
666
- }
667
- })
668
- }
669
-
670
- const handleMove = async (targetPath) => {
671
- try {
672
- for (const id of selectedIds) {
673
- const file = items.find(i => i.cid === id)
674
- if (!file) continue
675
- const { name } = parseName(file.fileName)
676
- const newFileName = targetPath ? `${targetPath}/${name}` : name
677
- if (file.fileName !== newFileName) {
678
- await API.moveFile(id, newFileName)
679
- }
680
- }
681
- setSelectedIds([])
682
- setIsMoveModalOpen(false)
683
- addToast('已移动', 'success')
684
- refreshFiles()
685
- } catch { addToast('移动失败', 'error') }
686
- }
687
-
688
- const openRenameModal = (target) => {
689
- const isFolder = !!target.path
690
- const currentName = isFolder ? target.name : parseName(target.fileName).name
691
- setInputModal({
692
- title: isFolder ? '重命名文件夹' : '重命名文件',
693
- placeholder: '请输入新名称',
694
- defaultValue: currentName,
695
- confirmText: '重命名',
696
- onConfirm: async (newName) => {
697
- setInputModal(null)
698
- if (newName === currentName) return
699
- try {
700
- if (isFolder) {
701
- await API.renameFolder(target.path, newName)
702
- } else {
703
- const { folder } = parseName(target.fileName)
704
- const newFileName = folder ? `${folder}/${newName}` : newName
705
- await API.moveFile(target.cid, newFileName)
706
- }
707
- addToast('已重命名', 'success')
708
- refreshFiles()
709
- } catch { addToast('重命名失败', 'error') }
710
- }
711
- })
712
- }
713
-
714
- const processFiles = async (files) => {
715
- const prefix = currentPath ? currentPath + '/' : ''
716
- const newTransfers = []
717
-
718
- for (const file of Array.from(files)) {
719
- const fileName = prefix + file.name
720
-
721
- // Create transfer entry for progress tracking
722
- const transferId = `upload_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
723
- const transfer = {
724
- id: transferId,
725
- fileName: file.name,
726
- progress: 0,
727
- type: 'upload',
728
- status: 'uploading'
729
- }
730
- newTransfers.push(transfer)
731
- setTransfers(prev => [...prev, transfer])
732
-
733
- // Open transfer panel if there are new transfers
734
- if (newTransfers.length > 0) {
735
- setIsTransferPanelOpen(true)
736
- }
737
-
738
- try {
739
- const result = await API.publishFile(file, fileName)
740
- if (result.alreadyExists) {
741
- // Update transfer status
742
- setTransfers(prev => prev.map(t =>
743
- t.id === transferId ? { ...t, status: 'completed' } : t
744
- ))
745
- addToast(`${file.name} 已存在`, 'warning')
746
- } else {
747
- // Update transfer status
748
- setTransfers(prev => prev.map(t =>
749
- t.id === transferId ? { ...t, progress: 100, status: 'completed' } : t
750
- ))
751
- addToast(`${file.name} 上传成功`, 'success')
752
- }
753
- } catch (err) {
754
- setTransfers(prev => prev.map(t =>
755
- t.id === transferId ? { ...t, status: 'error' } : t
756
- ))
757
- addToast(`上传失败: ${file.name}`, 'error')
758
- }
759
- }
760
-
761
- // Remove completed transfers after a delay
762
- setTimeout(() => {
763
- setTransfers(prev => prev.filter(t => t.status === 'uploading'))
764
- }, 3000)
765
-
766
- refreshFiles()
767
- refreshStorageStats()
768
- }
769
-
770
- const createNewFolder = () => {
771
- setInputModal({
772
- title: '新建文件夹',
773
- placeholder: '请输入文件夹名称',
774
- confirmText: '创建',
775
- onConfirm: async (folderPath) => {
776
- setInputModal(null)
777
- const exists = items.some(f =>
778
- f.fileName === folderPath ||
779
- f.fileName.startsWith(folderPath + '/')
780
- )
781
- if (exists) {
782
- addToast('文件夹已存在', 'warning')
783
- return
784
- }
785
- try {
786
- const randomContent = Math.random().toString(36).substring(2, 10)
787
- const content = new File([randomContent], 'hello.txt', { type: 'text/plain' })
788
- await API.publishFile(content, `${folderPath}/hello.txt`)
789
- addToast('文件夹已创建', 'success')
790
- refreshFiles()
791
- } catch { addToast('创建失败', 'error') }
792
- }
793
- })
794
- }
795
-
796
- const handleCopyLink = () => {
797
- navigator.clipboard.writeText(`most://${shareItem.cid}`).then(() => {
798
- setCopied(true)
799
- setTimeout(() => setCopied(false), 2000)
800
- })
801
- }
802
-
803
- const [isDownloading, setIsDownloading] = useState(false)
804
-
805
- const handleDownloadSharedFile = async () => {
806
- if (!downloadLink.trim() || !downloadLink.startsWith('most://')) {
807
- addToast('链接格式应为 most://<cid>', 'warning')
808
- return
809
- }
810
- if (isDownloading) return
811
- setIsDownloading(true)
812
- try {
813
- const result = await API.downloadFile(downloadLink)
814
- setDownloadLink('')
815
- setIsDownloadModalOpen(false)
816
-
817
- if (result.alreadyExists) {
818
- addToast(`${result.fileName} 已存在`, 'warning')
819
- } else {
820
- const transfer = {
821
- id: result.taskId,
822
- fileName: '下载文件',
823
- progress: 0,
824
- type: 'download',
825
- status: 'uploading'
826
- }
827
- setTransfers(prev => [...prev, transfer])
828
- setIsTransferPanelOpen(true)
829
- addToast('下载已开始', 'info')
830
- }
831
- } catch (err) {
832
- addToast('下载失败', 'error')
833
- } finally {
834
- setIsDownloading(false)
835
- }
836
- }
837
-
838
- const handleCancelTransfer = async (transfer) => {
839
- if (transfer.type === 'download' && transfer.status === 'uploading') {
840
- try {
841
- await API.cancelDownload(transfer.id)
842
- // The WebSocket will handle the 'download:cancelled' event
843
- } catch (err) {
844
- addToast('取消失败', 'error')
845
- }
846
- }
847
- }
848
-
849
- const handleNavigate = (path) => {
850
- setCurrentFolderId(path || null)
851
- setSelectedIds([])
852
- }
853
-
854
- const handleCloseWelcome = () => {
855
- setShowWelcome(false)
856
- localStorage.setItem('mostbox_welcomed', 'true')
857
- }
858
-
859
- const handleShutdown = () => {
860
- setConfirmModal({
861
- title: '关闭服务',
862
- message: '确定要关闭服务吗?',
863
- confirmText: '关闭',
864
- danger: true,
865
- onConfirm: async () => {
866
- setConfirmModal(null)
867
- try {
868
- await fetch('/api/shutdown', { method: 'POST' })
869
- } catch { }
870
- window.close()
871
- }
872
- })
873
- }
874
-
875
- // WebSocket
876
- useEffect(() => {
877
- const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
878
- const ws = new WebSocket(`${protocol}//${location.host}/ws`)
879
- ws.onmessage = (e) => {
880
- try {
881
- const { event, data } = JSON.parse(e.data)
882
- if (event === 'publish:success' || event === 'download:success') {
883
- refreshFiles()
884
- refreshStorageStats()
885
- const taskId = data.taskId || data.fileName
886
- setTransfers(prev => prev.map(t =>
887
- (t.id === taskId || t.fileName === data.fileName) ? { ...t, progress: 100, status: 'completed' } : t
888
- ))
889
- if (event === 'download:success') {
890
- if (data.alreadyExists) {
891
- addToast(`${data.fileName} 已存在`, 'warning')
892
- } else {
893
- addToast(`${data.fileName} 下载完成`, 'success')
894
- }
895
- // Remove completed downloads after delay
896
- setTimeout(() => {
897
- setTransfers(prev => prev.filter(t => !(t.id === taskId && t.status === 'completed')))
898
- }, 3000)
899
- }
900
- }
901
- // Handle publish/upload progress
902
- if (event === 'publish:progress') {
903
- setTransfers(prev => prev.map(t => {
904
- if (data.file && t.fileName === data.file && t.type === 'upload') {
905
- // Calculate percent based on stage
906
- let progress = 50
907
- if (data.stage === 'calculating-cid') progress = 25
908
- else if (data.stage === 'uploading') progress = 75
909
- else if (data.stage === 'complete') progress = 100
910
- return { ...t, progress }
911
- }
912
- return t
913
- }))
914
- }
915
- // Handle download progress
916
- if (event === 'download:progress') {
917
- setTransfers(prev => prev.map(t =>
918
- t.id === data.taskId ? { ...t, progress: data.percent || 0, loaded: data.loaded, total: data.total } : t
919
- ))
920
- }
921
- // Handle download error
922
- if (event === 'download:error') {
923
- setTransfers(prev => prev.map(t =>
924
- t.id === data.taskId ? { ...t, status: 'error' } : t
925
- ))
926
- addToast(`下载失败: ${data.error}`, 'error')
927
- }
928
- // Handle download status (includes filename when known)
929
- if (event === 'download:status') {
930
- setTransfers(prev => prev.map(t =>
931
- t.id === data.taskId ? { ...t, fileName: data.file || t.fileName } : t
932
- ))
933
- }
934
- // Handle download cancelled
935
- if (event === 'download:cancelled') {
936
- setTransfers(prev => prev.map(t =>
937
- t.id === data.taskId ? { ...t, status: 'cancelled' } : t
938
- ))
939
- addToast('下载已取消', 'warning')
940
- }
941
- } catch { }
942
- }
943
- return () => ws.close()
944
- }, [])
945
-
946
- // Init
947
- useEffect(() => {
948
- const saved = localStorage.getItem('theme')
949
- if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
950
- setIsDarkMode(true)
951
- }
952
- refreshFiles()
953
- refreshTrash()
954
- refreshStorageStats()
955
- API.getStorageStats().then(s => setStorageStats(s)).catch(() => { })
956
- }, [])
957
-
958
- // Theme colors
959
- const bgPrimary = isDarkMode ? '#0f172a' : '#f8fafc'
960
- const bgSecondary = isDarkMode ? '#1e293b' : '#ffffff'
961
- const bgTertiary = isDarkMode ? '#334155' : '#f1f5f9'
962
- const textPrimary = isDarkMode ? '#f8fafc' : '#0f172a'
963
- const textSecondary = isDarkMode ? '#94a3b8' : '#64748b'
964
- const textMuted = isDarkMode ? '#64748b' : '#94a3b8'
965
- const borderColor = isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'
966
- const accentBlue = isDarkMode ? '#60a5fa' : '#3b82f6'
967
-
968
- const viewTitle = currentView === 'all' ? '全部内容' : currentView === 'starred' ? '收藏' : '回收站'
969
- const displayFiles = currentView === 'all' ? filteredFiles : currentView === 'starred' ? items.filter(i => i.starred) : []
970
- const displayFolders = currentView === 'starred' ? [] : folders
971
-
972
- // Breadcrumb parts
973
- const breadcrumbParts = generateBreadcrumbs(currentPath)
974
-
975
- return (
976
- <div style={{ display: 'flex', minHeight: '100vh', background: bgPrimary, color: textPrimary }}>
977
- <style>{`
978
- * { margin: 0; padding: 0; box-sizing: border-box; }
979
- body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
980
- ::-webkit-scrollbar { width: 6px; }
981
- ::-webkit-scrollbar-thumb { background: ${textMuted}; border-radius: 4px; }
982
- @keyframes toastSlideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
983
- `}</style>
984
-
985
- {/* Sidebar */}
986
- <div style={{ width: 200, background: bgTertiary, display: 'flex', flexDirection: 'column', borderRight: `1px solid ${borderColor}`, flexShrink: 0 }}>
987
- <div style={{ padding: '20px 16px', borderBottom: `1px solid ${borderColor}` }}>
988
- <h1 style={{ fontSize: 18, fontWeight: 700, color: accentBlue }}>Most.Box</h1>
989
- </div>
990
- <nav style={{ padding: '12px 8px', flex: 1 }}>
991
- {[{ id: 'all', icon: <Files size={18} />, label: '全部内容' }, { id: 'starred', icon: <Star size={18} />, label: '收藏' }, { id: 'trash', icon: <Trash2 size={18} />, label: '回收站' }].map(item => (
992
- <button
993
- key={item.id}
994
- onClick={() => { setCurrentView(item.id); setCurrentFolderId(null); setSelectedIds([]); setSearchQuery('') }}
995
- style={{
996
- width: '100%', display: 'flex', alignItems: 'center', gap: 10,
997
- padding: '10px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
998
- marginBottom: 4, background: currentView === item.id ? accentBlue + '20' : 'transparent',
999
- color: currentView === item.id ? accentBlue : textSecondary,
1000
- fontWeight: currentView === item.id ? 600 : 500, fontSize: 13
1001
- }}
1002
- >
1003
- {item.icon}
1004
- <span>{item.label}</span>
1005
- </button>
1006
- ))}
1007
- </nav>
1008
- <div style={{ padding: '12px 16px', borderTop: `1px solid ${borderColor}` }}>
1009
- <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
1010
- <HardDrive size={14} color={textSecondary} />
1011
- <span style={{ fontSize: 11, color: textSecondary }}>存储空间</span>
1012
- </div>
1013
- <div style={{ height: 4, borderRadius: 2, background: bgSecondary, overflow: 'hidden' }}>
1014
- <div style={{ width: `${storageStats.total > 0 ? (storageStats.used / storageStats.total) * 100 : 0}%`, height: '100%', background: accentBlue, transition: 'width 0.3s' }} />
1015
- </div>
1016
- <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: textSecondary, marginTop: 4 }}>
1017
- <span>{formatSize(storageStats.used)}</span>
1018
- <span>{storageStats.total > 0 ? formatSize(storageStats.total) : '-'}</span>
1019
- </div>
1020
- </div>
1021
- </div>
1022
-
1023
- {/* Main Content */}
1024
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
1025
- {/* Header */}
1026
- <header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 24px', borderBottom: `1px solid ${borderColor}`, background: bgSecondary, gap: 16 }}>
1027
- <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
1028
- <h2 style={{ fontSize: 16, fontWeight: 600 }}>{viewTitle}</h2>
1029
- <div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 12, background: bgTertiary, fontSize: 11, color: textSecondary }}>
1030
- <div style={{ width: 6, height: 6, borderRadius: '50%', background: peerCount > 0 ? '#22c55e' : '#f59e0b' }} />
1031
- {peerCount > 0 ? `${peerCount} 节点` : '等待连接'}
1032
- </div>
1033
- </div>
1034
- <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
1035
- {currentView !== 'trash' && (
1036
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', borderRadius: 8, background: bgTertiary, border: `1px solid ${borderColor}` }}>
1037
- <Search size={14} color={textSecondary} />
1038
- <input type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索..." style={{ background: 'none', border: 'none', outline: 'none', fontSize: 12, color: textPrimary, width: 120 }} />
1039
- {searchQuery && <button onClick={() => setSearchQuery('')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: textSecondary, padding: 0 }}><X size={12} /></button>}
1040
- </div>
1041
- )}
1042
- {currentView === 'trash' && trashItems.length > 0 && (
1043
- <button onClick={handleEmptyTrash} style={{ padding: '6px 12px', borderRadius: 8, border: 'none', background: 'rgba(239,68,68,0.1)', color: '#ef4444', fontSize: 12, fontWeight: 500, cursor: 'pointer' }}>
1044
- 清空回收站
1045
- </button>
1046
- )}
1047
- <button onClick={() => setIsTransferPanelOpen(true)} style={{ width: 32, height: 32, borderRadius: 8, border: 'none', background: bgTertiary, cursor: 'pointer', color: textSecondary, position: 'relative' }}>
1048
- <ArrowUpDown size={16} />
1049
- {transfers.length > 0 && <span style={{ position: 'absolute', top: -4, right: -4, width: 14, height: 14, borderRadius: '50%', background: '#ef4444', color: '#fff', fontSize: 9, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{transfers.length}</span>}
1050
- </button>
1051
- <button onClick={createNewFolder} style={{ width: 32, height: 32, borderRadius: 8, border: 'none', background: bgTertiary, cursor: 'pointer', color: accentBlue }}>
1052
- <FolderPlus size={16} />
1053
- </button>
1054
- <button onClick={() => setIsDarkMode(!isDarkMode)} style={{ width: 32, height: 32, borderRadius: 8, border: 'none', background: bgTertiary, cursor: 'pointer', color: '#6366f1' }}>
1055
- {isDarkMode ? <Sun size={16} /> : <Moon size={16} />}
1056
- </button>
1057
- <button onClick={handleShutdown} title="关闭服务" style={{ width: 32, height: 32, borderRadius: 8, border: 'none', background: bgTertiary, cursor: 'pointer', color: '#ef4444' }}>
1058
- <Power size={16} />
1059
- </button>
1060
- <button onClick={() => setShowSettings(true)} style={{ width: 32, height: 32, borderRadius: 8, border: 'none', background: bgTertiary, cursor: 'pointer', color: textSecondary }}>
1061
- <Info size={16} />
1062
- </button>
1063
- </div>
1064
- </header>
1065
-
1066
- {/* Upload/Download */}
1067
- {currentView === 'all' && (
1068
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, padding: '16px 24px', position: 'relative' }}>
1069
- <div onDragOver={(e) => { e.preventDefault(); setIsDraggingOverUpload(true) }} onDragLeave={() => setIsDraggingOverUpload(false)} onDrop={(e) => { e.preventDefault(); setIsDraggingOverUpload(false); processFiles(e.dataTransfer.files) }} style={{ border: `2px dashed ${isDraggingOverUpload ? '#3b82f6' : 'rgba(59,130,246,0.2)'}`, borderRadius: 12, padding: 20, textAlign: 'center', cursor: 'pointer', background: isDraggingOverUpload ? 'rgba(59,130,246,0.05)' : 'transparent', position: 'relative' }}>
1070
- <input type="file" multiple onChange={(e) => processFiles(e.target.files)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', zIndex: 1 }} />
1071
- <Upload size={20} color={accentBlue} style={{ marginBottom: 8 }} />
1072
- <p style={{ fontSize: 12, color: accentBlue, fontWeight: 500 }}>上传文件</p>
1073
- </div>
1074
- <div onClick={() => setIsDownloadModalOpen(true)} style={{ border: '2px dashed rgba(99,102,241,0.2)', borderRadius: 12, padding: 20, textAlign: 'center', cursor: 'pointer' }}>
1075
- <Download size={20} color="#6366f1" style={{ marginBottom: 8 }} />
1076
- <p style={{ fontSize: 12, color: '#6366f1', fontWeight: 500 }}>下载文件</p>
1077
- </div>
1078
- </div>
1079
- )}
1080
-
1081
- {/* Breadcrumb */}
1082
- {currentView === 'all' && currentPath && (
1083
- <div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '0 24px 12px', fontSize: 12 }}>
1084
- <button onClick={() => handleNavigate('')} style={{ background: 'none', border: 'none', color: textMuted, cursor: 'pointer' }}>全部内容</button>
1085
- {breadcrumbParts.slice(1).map((part, i) => (
1086
- <React.Fragment key={part.path}>
1087
- <ChevronRight size={12} color={textMuted} />
1088
- <button onClick={() => handleNavigate(part.path)} style={{ background: 'none', border: 'none', color: i === breadcrumbParts.length - 2 ? textSecondary : textMuted, cursor: 'pointer' }}>{part.name}</button>
1089
- </React.Fragment>
1090
- ))}
1091
- </div>
1092
- )}
1093
-
1094
- {/* Content Grid */}
1095
- <div style={{ flex: 1, padding: '0 24px 24px', overflow: 'auto' }}>
1096
- {/* Trash View */}
1097
- {currentView === 'trash' && (
1098
- trashItems.length === 0 ? (
1099
- <div style={{ textAlign: 'center', color: textMuted, padding: 48, fontSize: 13 }}>回收站是空的</div>
1100
- ) : (
1101
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 16 }}>
1102
- {trashItems.map(f => (
1103
- <div
1104
- key={f.cid}
1105
- onClick={() => setSelectedIds(prev => prev.includes(f.cid) ? prev.filter(id => id !== f.cid) : [...prev, f.cid])}
1106
- onDoubleClick={() => handleRestore(f.cid)}
1107
- style={{
1108
- display: 'flex', flexDirection: 'column', alignItems: 'center',
1109
- padding: 16, borderRadius: 12,
1110
- background: selectedIds.includes(f.cid) ? accentBlue + '15' : bgSecondary,
1111
- border: `1px solid ${selectedIds.includes(f.cid) ? accentBlue : borderColor}`,
1112
- transition: 'all 0.15s', cursor: 'pointer'
1113
- }}
1114
- >
1115
- <div style={{ ...iconContainerStyle, background: 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)' }}>
1116
- <FileText size={24} color="#fff" />
1117
- </div>
1118
- <p style={textEllipsisStyle}>{parseName(f.fileName).name}</p>
1119
- <p style={{ fontSize: 10, color: textMuted, marginTop: 2 }}>删除于 {formatDate(f.deletedAt)}</p>
1120
- </div>
1121
- ))}
1122
- </div>
1123
- )
1124
- )}
1125
-
1126
- {/* All/Starred View */}
1127
- {currentView !== 'trash' && (
1128
- displayFiles.length === 0 && displayFolders.length === 0 ? (
1129
- <div style={{ textAlign: 'center', color: textMuted, padding: 48, fontSize: 13 }}>
1130
- {searchQuery ? '未找到相关文件' : (currentView === 'starred' ? '暂无收藏' : '暂无文件')}
1131
- </div>
1132
- ) : (
1133
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 16 }}>
1134
- {displayFolders.map(folder => (
1135
- <FolderCard key={folder.path} folder={folder} isDarkMode={isDarkMode} onClick={() => handleNavigate(folder.path)} />
1136
- ))}
1137
- {displayFiles.map(f => (
1138
- <FileCard key={f.cid} file={f} isSelected={selectedIds.includes(f.cid)} isDarkMode={isDarkMode} onSelect={handleSelect} onPreview={(file) => setPreviewItem({ ...file, subtype: getFileSubtype(file.fileName) })} />
1139
- ))}
1140
- </div>
1141
- )
1142
- )}
1143
- </div>
1144
- </div>
1145
-
1146
-
1147
-
1148
- {/* Confirm Modal */}
1149
- {confirmModal && (
1150
- <ConfirmModal
1151
- title={confirmModal.title}
1152
- message={confirmModal.message}
1153
- confirmText={confirmModal.confirmText}
1154
- danger={confirmModal.danger}
1155
- onConfirm={confirmModal.onConfirm}
1156
- onClose={() => setConfirmModal(null)}
1157
- />
1158
- )}
1159
-
1160
- {/* Input Modal */}
1161
- {inputModal && (
1162
- <InputModal
1163
- title={inputModal.title}
1164
- placeholder={inputModal.placeholder}
1165
- confirmText={inputModal.confirmText}
1166
- onConfirm={inputModal.onConfirm}
1167
- onClose={() => setInputModal(null)}
1168
- />
1169
- )}
1170
-
1171
- {/* Move Modal */}
1172
- {isMoveModalOpen && (
1173
- <MoveModal
1174
- items={selectedIds.map(id => items.find(i => i.cid === id)).filter(Boolean)}
1175
- allFolders={allFolders.map(path => ({ path, name: path.split('/').pop() }))}
1176
- currentPath={currentPath}
1177
- isDarkMode={isDarkMode}
1178
- onMove={handleMove}
1179
- onClose={() => setIsMoveModalOpen(false)}
1180
- />
1181
- )}
1182
-
1183
- {/* Share Modal */}
1184
- {shareItem && (
1185
- <ModalOverlay onClose={() => setShareItem(null)}>
1186
- <div style={{ width: 420, padding: 24, borderRadius: 16, background: bgSecondary, border: `1px solid ${borderColor}` }} onClick={e => e.stopPropagation()}>
1187
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
1188
- <h3 style={{ fontSize: 16, fontWeight: 600 }}>分享链接</h3>
1189
- <button onClick={() => setShareItem(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: textMuted }}><X size={18} /></button>
1190
- </div>
1191
- <div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
1192
- <div style={{ flex: 1, padding: '12px 14px', borderRadius: 10, background: bgTertiary, fontSize: 13, fontFamily: 'monospace', color: textPrimary, wordBreak: 'break-all' }}>most://{shareItem.cid}</div>
1193
- <button onClick={handleCopyLink} style={{ width: 44, borderRadius: 10, border: 'none', background: copied ? '#22c55e' : accentBlue, color: '#fff', cursor: 'pointer' }}>
1194
- {copied ? <Check size={18} /> : <Copy size={18} />}
1195
- </button>
1196
- </div>
1197
- </div>
1198
- </ModalOverlay>
1199
- )}
1200
-
1201
- {/* Download Modal */}
1202
- {isDownloadModalOpen && (
1203
- <ModalOverlay onClose={() => setIsDownloadModalOpen(false)}>
1204
- <div style={{ width: 400, padding: 24, borderRadius: 16, background: bgSecondary, border: `1px solid ${borderColor}` }} onClick={e => e.stopPropagation()}>
1205
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
1206
- <h3 style={{ fontSize: 16, fontWeight: 600 }}>下载文件</h3>
1207
- <button onClick={() => setIsDownloadModalOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: textMuted }}><X size={18} /></button>
1208
- </div>
1209
- <input type="text" value={downloadLink} onChange={(e) => setDownloadLink(e.target.value)} placeholder="most://..." onKeyDown={(e) => e.key === 'Enter' && handleDownloadSharedFile()} style={{ width: '100%', padding: '12px 14px', borderRadius: 10, border: `1.5px solid ${borderColor}`, fontSize: 13, fontFamily: 'monospace', outline: 'none', background: bgTertiary, color: textPrimary, marginBottom: 16 }} />
1210
- <button onClick={handleDownloadSharedFile} disabled={!downloadLink.trim() || isDownloading} style={{ width: '100%', padding: 12, borderRadius: 10, border: 'none', background: (downloadLink.trim() && !isDownloading) ? '#6366f1' : bgTertiary, color: (downloadLink.trim() && !isDownloading) ? '#fff' : textMuted, fontSize: 13, fontWeight: 600, cursor: (downloadLink.trim() && !isDownloading) ? 'pointer' : 'not-allowed' }}>
1211
- {isDownloading ? '下载中...' : '开始下载'}
1212
- </button>
1213
- </div>
1214
- </ModalOverlay>
1215
- )}
1216
-
1217
- {/* Preview Modal */}
1218
- {previewItem && (
1219
- <div style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => setPreviewItem(null)}>
1220
- <button onClick={() => setPreviewItem(null)} style={{ position: 'absolute', top: 20, right: 20, width: 36, height: 36, borderRadius: 10, border: 'none', background: 'rgba(255,255,255,0.1)', cursor: 'pointer', color: '#fff' }}><X size={20} /></button>
1221
- <div onClick={e => e.stopPropagation()}>
1222
- {previewItem.subtype === 'image' && <img src={API.getFileDownloadUrl(previewItem.cid)} alt="" style={{ maxWidth: '100%', maxHeight: '80vh', borderRadius: 12 }} />}
1223
- {previewItem.subtype === 'video' && <video src={API.getFileDownloadUrl(previewItem.cid)} controls style={{ maxWidth: '100%', maxHeight: '80vh', borderRadius: 12 }} />}
1224
- {previewItem.subtype === 'audio' && <div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: 32 }}><Music size={48} color="#fff" style={{ marginBottom: 12 }} /><audio src={API.getFileDownloadUrl(previewItem.cid)} controls /></div>}
1225
- </div>
1226
- </div>
1227
- )}
1228
-
1229
- {/* Batch Actions Bar */}
1230
- {selectedIds.length > 0 && (
1231
- <div style={{ position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)', display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', borderRadius: 12, background: bgSecondary, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', border: `1px solid ${borderColor}`, zIndex: 100 }}>
1232
- <span style={{ fontSize: 12, color: textSecondary }}>已选 {selectedIds.length} 项</span>
1233
- <button onClick={() => setSelectedIds([])} style={{ background: 'none', border: 'none', cursor: 'pointer', color: textMuted, padding: 4 }}><X size={16} /></button>
1234
- <div style={{ width: 1, height: 20, background: borderColor }} />
1235
- {currentView === 'trash' ? (
1236
- <>
1237
- <button onClick={() => selectedIds.forEach(cid => handleRestore(cid))} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 8, border: 'none', fontSize: 12, cursor: 'pointer' }}>
1238
- 恢复
1239
- </button>
1240
- <button onClick={handleBatchDelete} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 8, border: 'none', background: '#ef4444', color: '#fff', fontSize: 12, cursor: 'pointer' }}>
1241
- 永久删除
1242
- </button>
1243
- </>
1244
- ) : (
1245
- <>
1246
- <button onClick={() => {
1247
- const hasUnstarred = selectedIds.some(id => {
1248
- const item = items.find(i => i.cid === id)
1249
- return item && !item.starred
1250
- })
1251
- selectedIds.forEach(id => {
1252
- const item = items.find(i => i.cid === id)
1253
- if (item && (hasUnstarred ? !item.starred : item.starred)) {
1254
- handleToggleStar(id)
1255
- }
1256
- })
1257
- }} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 8, border: 'none', background: '#f59e0b', color: '#fff', fontSize: 12, cursor: 'pointer' }}>
1258
- 收藏
1259
- </button>
1260
- <button onClick={() => {
1261
- const firstSelected = items.find(i => i.cid === selectedIds[0])
1262
- if (firstSelected) openRenameModal(firstSelected)
1263
- }} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 8, border: 'none', background: bgTertiary, color: textPrimary, fontSize: 12, cursor: 'pointer' }}>
1264
- 重命名
1265
- </button>
1266
- <button onClick={() => setIsMoveModalOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 8, border: 'none', background: accentBlue, color: '#fff', fontSize: 12, cursor: 'pointer' }}>
1267
- 移动
1268
- </button>
1269
- <button onClick={handleBatchDelete} style={{ padding: '6px 12px', borderRadius: 8, border: 'none', background: '#ef4444', color: '#fff', fontSize: 12, cursor: 'pointer' }}>删除</button>
1270
- <button onClick={() => setShareItem(items.find(i => i.cid === selectedIds[0]))} style={{ padding: '6px 12px', borderRadius: 8, border: 'none', background: bgTertiary, color: textPrimary, fontSize: 12, cursor: 'pointer' }}>分享</button>
1271
- </>
1272
- )}
1273
- </div>
1274
- )}
1275
-
1276
- {/* Transfer Panel */}
1277
- {isTransferPanelOpen && (
1278
- <ModalOverlay onClose={() => setIsTransferPanelOpen(false)}>
1279
- <div style={{ width: 380, maxHeight: '70vh', padding: 24, borderRadius: 16, background: bgSecondary, border: `1px solid ${borderColor}`, overflow: 'auto' }} onClick={e => e.stopPropagation()}>
1280
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
1281
- <h3 style={{ fontSize: 16, fontWeight: 600 }}>传输</h3>
1282
- <button onClick={() => setIsTransferPanelOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: textMuted }}><X size={18} /></button>
1283
- </div>
1284
- {transfers.length === 0 ? (
1285
- <div style={{ textAlign: 'center', color: textMuted, padding: 24, fontSize: 13 }}>
1286
- 暂无传输
1287
- </div>
1288
- ) : (
1289
- transfers.map(t => (
1290
- <div key={t.id} style={{ padding: '10px 0', borderBottom: `1px solid ${borderColor}` }}>
1291
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
1292
- {t.type === 'upload' ? <Upload size={14} color={accentBlue} /> : <Download size={14} color="#6366f1" />}
1293
- <span style={{ fontSize: 12, fontWeight: 500, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.fileName}</span>
1294
- {t.status === 'uploading' && t.type === 'download' && (
1295
- <button onClick={() => handleCancelTransfer(t)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 2 }}>
1296
- <X size={14} />
1297
- </button>
1298
- )}
1299
- </div>
1300
- <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
1301
- <div style={{ flex: 1, height: 4, borderRadius: 2, background: bgTertiary }}>
1302
- <div style={{
1303
- width: `${t.progress}%`,
1304
- height: '100%',
1305
- borderRadius: 2,
1306
- background: t.status === 'error' ? '#ef4444' : t.status === 'cancelled' ? '#f59e0b' : t.type === 'upload' ? accentBlue : '#6366f1',
1307
- transition: 'width 0.2s'
1308
- }} />
1309
- </div>
1310
- <span style={{ fontSize: 10, color: textMuted, minWidth: 32, textAlign: 'right' }}>
1311
- {t.status === 'completed' ? '完成' :
1312
- t.status === 'error' ? '失败' :
1313
- t.status === 'cancelled' ? '已取消' :
1314
- t.loaded && t.total ? `${formatSize(t.loaded)}/${formatSize(t.total)}` :
1315
- `${t.progress}%`}
1316
- </span>
1317
- </div>
1318
- </div>
1319
- ))
1320
- )}
1321
- </div>
1322
- </ModalOverlay>
1323
- )}
1324
-
1325
- {/* Toasts */}
1326
- {toasts.map((t, i) => <Toast key={t.id} message={t.message} type={t.type} onDone={() => removeToast(t.id)} index={i} />)}
1327
-
1328
- {/* Welcome Guide */}
1329
- {showWelcome && <WelcomeGuide onClose={handleCloseWelcome} />}
1330
-
1331
- {/* Settings Modal */}
1332
- {showSettings && <SettingsModal onClose={() => setShowSettings(false)} addToast={addToast} />}
1333
- </div>
1334
- )
1335
- }