most-box 0.0.2 → 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 (68) hide show
  1. package/README.md +26 -0
  2. package/cli.js +2 -2
  3. package/out/404/index.html +15 -0
  4. package/out/404.html +15 -0
  5. package/out/__next.__PAGE__.txt +9 -0
  6. package/out/__next._full.txt +18 -0
  7. package/out/__next._head.txt +5 -0
  8. package/out/__next._index.txt +6 -0
  9. package/out/__next._tree.txt +2 -0
  10. package/out/_next/static/0h4f4QFk_KC9FlSRfQACk/_buildManifest.js +11 -0
  11. package/out/_next/static/0h4f4QFk_KC9FlSRfQACk/_clientMiddlewareManifest.js +1 -0
  12. package/out/_next/static/0h4f4QFk_KC9FlSRfQACk/_ssgManifest.js +1 -0
  13. package/out/_next/static/chunks/00l-yd3t8dvwz.js +5 -0
  14. package/out/_next/static/chunks/03k8t3tgym~8~.js +1 -0
  15. package/out/_next/static/chunks/03~yq9q893hmn.js +1 -0
  16. package/out/_next/static/chunks/09vfh8lfuacc0.css +1 -0
  17. package/out/_next/static/chunks/0bogtdbh.dcu1.js +1 -0
  18. package/out/_next/static/chunks/0dbhjjzl8qfwv.js +1 -0
  19. package/out/_next/static/chunks/0f73psqhr8dre.css +1 -0
  20. package/out/_next/static/chunks/0fbi7z4_.4j1j.js +1 -0
  21. package/out/_next/static/chunks/0ht900cau6_ur.js +31 -0
  22. package/out/_next/static/chunks/0ohm.ia-4ec60.js +1 -0
  23. package/out/_next/static/chunks/0u5ydb-f0.vxl.js +1 -0
  24. package/out/_next/static/chunks/14t2m1on-s5v~.js +1 -0
  25. package/out/_next/static/chunks/turbopack-076ce9exut_h3.js +1 -0
  26. package/out/_not-found/__next._full.txt +16 -0
  27. package/out/_not-found/__next._head.txt +5 -0
  28. package/out/_not-found/__next._index.txt +6 -0
  29. package/out/_not-found/__next._not-found/__PAGE__.txt +5 -0
  30. package/out/_not-found/__next._not-found.txt +5 -0
  31. package/out/_not-found/__next._tree.txt +2 -0
  32. package/out/_not-found/index.html +15 -0
  33. package/out/_not-found/index.txt +16 -0
  34. package/out/app.css +1535 -0
  35. package/out/bundle.js +107 -0
  36. package/out/bundle.js.map +7 -0
  37. package/out/chat/__next._full.txt +19 -0
  38. package/out/chat/__next._head.txt +5 -0
  39. package/out/chat/__next._index.txt +6 -0
  40. package/out/chat/__next._tree.txt +3 -0
  41. package/out/chat/__next.chat/__PAGE__.txt +9 -0
  42. package/out/chat/__next.chat.txt +5 -0
  43. package/out/chat/index.html +15 -0
  44. package/out/chat/index.txt +19 -0
  45. package/out/chat-page.js +112 -0
  46. package/out/chat.css +378 -0
  47. package/out/favicon.ico +0 -0
  48. package/out/index.html +15 -0
  49. package/out/index.js +148 -0
  50. package/out/index.txt +18 -0
  51. package/package.json +11 -6
  52. package/public/app.css +20 -4
  53. package/public/bundle.js +1 -1
  54. package/public/bundle.js.map +7 -0
  55. package/public/chat-page.js +112 -0
  56. package/public/chat.css +378 -0
  57. package/public/index.js +148 -0
  58. package/server.js +188 -6
  59. package/src/config.js +12 -1
  60. package/src/core/cid.js +7 -3
  61. package/src/index.js +475 -7
  62. package/src/utils/api.js +6 -0
  63. package/build.mjs +0 -40
  64. package/public/app.jsx +0 -1543
  65. package/public/bundle.css +0 -1
  66. package/public/error-boundary.jsx +0 -50
  67. package/public/index.html +0 -16
  68. package/public/index.jsx +0 -20
package/public/app.jsx DELETED
@@ -1,1543 +0,0 @@
1
- import React, { useState, useEffect, useRef } from 'react'
2
- import {
3
- Upload, Sun, Moon, Image as ImageIcon, Trash2, Folder,
4
- Film, Music, ChevronRight, FileText,
5
- X, Check, Copy, Download, ArrowUpDown, Star, Files, HardDrive, Search, Info,
6
- FolderOpen, Power, Edit2, Menu, Eye, Loader
7
- } from 'lucide-react'
8
-
9
- // === 接口 ===
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
- // === 工具函数 ===
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
- const txtExts = ['txt', 'md', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'less', 'json', 'xml', 'html', 'htm', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'log', 'sh', 'bash', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'php', 'sql', 'graphql', 'env', 'gitignore', 'dockerfile', 'readme']
123
- if (imgExts.includes(ext)) return 'image'
124
- if (vidExts.includes(ext)) return 'video'
125
- if (audExts.includes(ext)) return 'audio'
126
- if (txtExts.includes(ext)) return 'text'
127
- return 'file'
128
- }
129
-
130
- // === 引导页 ===
131
- function WelcomeGuide({ onClose, onShutdown }) {
132
- const [step, setStep] = useState(0)
133
- const [customPath, setCustomPath] = useState('')
134
- const [saving, setSaving] = useState(false)
135
- const [saved, setSaved] = useState(false)
136
- const [defaultPath, setDefaultPath] = useState('')
137
-
138
- useEffect(() => {
139
- API.getDataPath().then(config => {
140
- setDefaultPath(config.dataPath || '')
141
- }).catch(() => { })
142
- }, [])
143
-
144
- const steps = [
145
- { title: '欢迎使用', content: '拖拽文件到上传区,或点击选择文件。上传后复制链接发给朋友即可。' },
146
- { title: '下载文件', content: '点击「下载文件」,粘贴分享链接即可从 P2P 网络下载文件。' },
147
- { title: '设置存储位置', content: '选择文件存储的文件夹位置(可选,默认使用系统盘)', isOptional: true }
148
- ]
149
- const current = steps[step]
150
-
151
- const handleSavePath = async () => {
152
- if (!customPath.trim()) return
153
- setSaving(true)
154
- try {
155
- await API.saveConfig({ dataPath: customPath.trim() })
156
- } catch (err) {
157
- console.error('Save path error:', err)
158
- setSaving(false)
159
- return
160
- }
161
- setSaving(false)
162
- setSaved(true)
163
- }
164
-
165
- const isLastStep = step === steps.length - 1
166
- const isPathStep = step === 2
167
-
168
- return (
169
- <ModalOverlay onClose={onClose} closeOnOverlayClick={false}>
170
- <div className="welcome-modal" onClick={e => e.stopPropagation()}>
171
- {saved ? (
172
- <>
173
- <div className="welcome-success-icon">
174
- <Check size={24} color="#22c55e" />
175
- </div>
176
- <h2>设置已保存</h2>
177
- <p>存储位置已更改,需要重启应用生效。</p>
178
- <button
179
- onClick={onShutdown}
180
- className="btn primary"
181
- >
182
- 好的
183
- </button>
184
- </>
185
- ) : (
186
- <>
187
- <h2>{current.title}</h2>
188
- <p>{current.content}</p>
189
-
190
- {isPathStep && (
191
- <div className="welcome-path-section">
192
- <div>
193
- <div className="path-label">当前存储位置</div>
194
- <div className="path-value">{defaultPath || '未设置'}</div>
195
- </div>
196
- <div>
197
- <div className="path-label">自定义位置</div>
198
- <input
199
- type="text"
200
- value={customPath}
201
- onChange={(e) => setCustomPath(e.target.value)}
202
- placeholder="如 D:\"
203
- className="path-input"
204
- />
205
- <div className="path-hint">不填则使用当前位置,修改后需重启应用</div>
206
- </div>
207
- </div>
208
- )}
209
-
210
- <div className="welcome-steps">
211
- {steps.map((_, i) => (
212
- <div key={i} className={`welcome-step-dot ${i === step ? 'active' : ''}`} />
213
- ))}
214
- </div>
215
-
216
- <div className="welcome-actions">
217
- {isPathStep && (
218
- <button
219
- onClick={onClose}
220
- className="btn secondary"
221
- >
222
- 跳过
223
- </button>
224
- )}
225
- <button
226
- onClick={() => {
227
- if (isPathStep && customPath) {
228
- handleSavePath()
229
- } else if (isLastStep) {
230
- onClose()
231
- } else {
232
- setStep(step + 1)
233
- }
234
- }}
235
- disabled={isPathStep && saving}
236
- className="btn primary"
237
- style={{ opacity: isPathStep && saving ? 0.6 : 1, cursor: isPathStep && saving ? 'not-allowed' : 'pointer' }}
238
- >
239
- {isPathStep ? (saving ? '保存中...' : '保存并完成') : (isLastStep ? '开始使用' : '下一步')}
240
- </button>
241
- </div>
242
- </>
243
- )}
244
- </div>
245
- </ModalOverlay>
246
- )
247
- }
248
-
249
- // === 设置弹窗 ===
250
- function SettingsModal({ onClose, addToast, isDarkMode, handleShutdown }) {
251
- const [dataPath, setStoragePath] = useState('')
252
- const [originalPath, setOriginalPath] = useState('')
253
- const [isDefault, setIsDefault] = useState(false)
254
- const [loading, setLoading] = useState(true)
255
- const [saving, setSaving] = useState(false)
256
-
257
- useEffect(() => {
258
- API.getDataPath().then(config => {
259
- const path = config.dataPath || ''
260
- setStoragePath(path)
261
- setOriginalPath(path)
262
- setIsDefault(config.isDefault || false)
263
- setLoading(false)
264
- }).catch(() => setLoading(false))
265
- }, [])
266
-
267
- const handleSavePath = async () => {
268
- if (!dataPath.trim()) return
269
- if (dataPath.trim() === originalPath) return
270
- setSaving(true)
271
- try {
272
- await API.saveConfig({ dataPath: dataPath.trim() })
273
- await fetch('/api/shutdown', { method: 'POST' })
274
- window.close()
275
- } catch (err) {
276
- addToast(err.message || '保存失败', 'error')
277
- setSaving(false)
278
- }
279
- }
280
-
281
- const handleResetPath = async () => {
282
- if (originalPath === '') return
283
- setSaving(true)
284
- try {
285
- await API.saveConfig({ resetStorage: true })
286
- await fetch('/api/shutdown', { method: 'POST' })
287
- window.close()
288
- } catch (err) {
289
- addToast(err.message || '操作失败', 'error')
290
- setSaving(false)
291
- }
292
- }
293
-
294
- const isPathChanged = dataPath.trim() !== originalPath
295
-
296
- return (
297
- <ModalOverlay onClose={onClose}>
298
- <div className="settings-modal" onClick={e => e.stopPropagation()}>
299
- <div className="modal-header">
300
- <h2 className="settings-title">设置</h2>
301
- <button onClick={onClose} className="modal-close-btn"><X size={18} /></button>
302
- </div>
303
-
304
- <div style={{ marginBottom: 20 }}>
305
- <label className="settings-label">存储位置</label>
306
- <div className="settings-row">
307
- <input
308
- type="text"
309
- value={dataPath}
310
- onChange={(e) => setStoragePath(e.target.value)}
311
- placeholder="如 D:\most-data"
312
- disabled={loading}
313
- className="settings-input"
314
- />
315
- <button onClick={handleSavePath} disabled={saving || loading || !isPathChanged} className="btn primary" style={{ whiteSpace: 'nowrap', opacity: saving || loading || !isPathChanged ? 0.5 : 1 }}>
316
- {saving ? '保存中...' : '保存'}
317
- </button>
318
- {!isDefault && (
319
- <button onClick={handleResetPath} disabled={saving || loading} className="btn secondary" style={{ whiteSpace: 'nowrap', opacity: saving || loading ? 0.5 : 1 }}>
320
- 恢复默认
321
- </button>
322
- )}
323
- </div>
324
- <p className="settings-hint">修改后需重启应用</p>
325
- </div>
326
-
327
- <div className="settings-divider">
328
- <div className="settings-about">
329
- <h3>MostBox</h3>
330
- <p>版本 0.0.1</p>
331
- </div>
332
- <p style={{ fontSize: 12, textAlign: 'center', color: 'var(--text-secondary)' }}>Hyperswarm · Hyperdrive · IPFS</p>
333
- </div>
334
-
335
- <button onClick={() => { onClose(); handleShutdown(); }} className="btn danger full" style={{ marginTop: 20 }}>
336
- <Power size={16} /> 关闭服务
337
- </button>
338
- </div>
339
- </ModalOverlay>
340
- )
341
- }
342
-
343
- function Toast({ message, type, onDone, index }) {
344
- useEffect(() => {
345
- const t = setTimeout(onDone, 3000)
346
- return () => clearTimeout(t)
347
- }, [])
348
- return (
349
- <div className={`toast ${type}`} style={{ bottom: 24 + index * 60 }}>
350
- {message}
351
- </div>
352
- )
353
- }
354
-
355
- // === 遮罩层 ===
356
- function ModalOverlay({ children, onClose, closeOnOverlayClick = false }) {
357
- const handleOverlayClick = (e) => {
358
- if (closeOnOverlayClick && e.target === e.currentTarget) {
359
- onClose?.()
360
- }
361
- }
362
- return (
363
- <div className="modal-overlay" onClick={handleOverlayClick}>
364
- {children}
365
- </div>
366
- )
367
- }
368
-
369
- // === 面包屑生成器 ===
370
- function generateBreadcrumbs(currentPath) {
371
- if (!currentPath) return []
372
- return [
373
- { path: '', name: '全部内容' },
374
- ...currentPath.split('/').filter(Boolean).map((part, i, arr) => ({
375
- path: arr.slice(0, i + 1).join('/'),
376
- name: part
377
- }))
378
- ]
379
- }
380
-
381
- // === 刷新处理器工厂 ===
382
- const createRefreshHandler = (setter, apiMethod) => async () => {
383
- try { setter(await apiMethod()) }
384
- catch (err) { console.error(err) }
385
- }
386
-
387
- // === 文件卡片 ===
388
- function FileCard({ file, isSelected, isDarkMode, onSelect, onPreview }) {
389
- const subtype = getFileSubtype(file.fileName)
390
-
391
- return (
392
- <div
393
- data-id={file.cid}
394
- onClick={() => onSelect(file.cid)}
395
- onDoubleClick={() => onPreview(file)}
396
- className={`card ${isSelected ? 'selected' : ''}`}
397
- >
398
- <div className={`card-icon ${file.starred ? 'starred' : 'file'}`}>
399
- {subtype === 'image' && <ImageIcon size={24} color="#fff" />}
400
- {subtype === 'video' && <Film size={24} color="#fff" />}
401
- {subtype === 'audio' && <Music size={24} color="#fff" />}
402
- {subtype === 'file' && <FileText size={24} color="#fff" />}
403
- </div>
404
- <p className="card-name">
405
- {parseName(file.fileName).name}
406
- </p>
407
- </div>
408
- )
409
- }
410
-
411
- // === 文件夹卡片 ===
412
- function FolderCard({ folder, isDarkMode, onClick }) {
413
- return (
414
- <div
415
- onClick={onClick}
416
- className="card"
417
- >
418
- <div className="card-icon folder">
419
- <Folder size={28} color="#fff" />
420
- </div>
421
- <p className="card-name">
422
- {folder.name}
423
- </p>
424
- </div>
425
- )
426
- }
427
-
428
- // === 确认弹窗 ===
429
- function ConfirmModal({ title, message, confirmText, onConfirm, onClose, danger, isDarkMode, closeOnOverlayClick }) {
430
- return (
431
- <ModalOverlay onClose={onClose} closeOnOverlayClick={closeOnOverlayClick}>
432
- <div className="confirm-modal" onClick={e => e.stopPropagation()}>
433
- <h3>{title}</h3>
434
- <p>{message}</p>
435
- <div className="modal-actions">
436
- <button onClick={onClose} className="btn secondary">取消</button>
437
- <button onClick={onConfirm} className={`btn ${danger ? 'danger' : 'primary'}`}>{confirmText}</button>
438
- </div>
439
- </div>
440
- </ModalOverlay>
441
- )
442
- }
443
-
444
- // === 输入弹窗 ===
445
- function InputModal({ title, placeholder, defaultValue, confirmText, onConfirm, onClose, isDarkMode, isLoading, loadingText }) {
446
- const [value, setValue] = useState(defaultValue || '')
447
- return (
448
- <ModalOverlay onClose={onClose}>
449
- <div className="input-modal" onClick={e => e.stopPropagation()}>
450
- <h3>{title}</h3>
451
- <input
452
- type="text"
453
- value={value}
454
- onChange={(e) => setValue(e.target.value)}
455
- placeholder={placeholder}
456
- autoFocus
457
- onKeyDown={(e) => { if (e.key === 'Enter' && value.trim() && !isLoading) onConfirm(value.trim()) }}
458
- className="modal-input"
459
- />
460
- <div className="modal-actions">
461
- <button onClick={onClose} disabled={isLoading} className="btn secondary">取消</button>
462
- <button onClick={() => value.trim() && onConfirm(value.trim())} disabled={!value.trim() || isLoading} className="btn primary" style={{ opacity: isLoading ? 0.7 : 1 }}>{isLoading ? (loadingText || '处理中...') : confirmText}</button>
463
- </div>
464
- </div>
465
- </ModalOverlay>
466
- )
467
- }
468
-
469
- // === 移动弹窗 ===
470
- function MoveModal({ items, allFolders, currentPath, isDarkMode, onMove, onClose }) {
471
- const [targetPath, setTargetPath] = useState('')
472
- const [customPath, setCustomPath] = useState(currentPath)
473
-
474
- const breadcrumbParts = generateBreadcrumbs(targetPath)
475
-
476
- const handleConfirm = () => {
477
- const finalPath = targetPath || customPath.trim()
478
- onMove(finalPath)
479
- }
480
-
481
- return (
482
- <ModalOverlay onClose={onClose}>
483
- <div className="move-modal" onClick={e => e.stopPropagation()}>
484
- <div className="modal-header">
485
- <h3>移动到</h3>
486
- <button onClick={onClose} className="modal-close-btn"><X size={18} /></button>
487
- </div>
488
- <p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 12 }}>已选 {items.length} 个项目</p>
489
- <div className="move-new-folder">
490
- <input
491
- type="text"
492
- value={customPath}
493
- onChange={(e) => setCustomPath(e.target.value)}
494
- placeholder="输入路径创建嵌套文件夹"
495
- />
496
- </div>
497
- <p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 12 }}>如 图片/壁纸</p>
498
- <div className="move-breadcrumb">
499
- {breadcrumbParts.map((part, i) => (
500
- <React.Fragment key={part.path}>
501
- {i > 0 && <span style={{ color: 'var(--text-secondary)' }}>/</span>}
502
- <button
503
- key={part.path}
504
- onClick={() => {
505
- setTargetPath(part.path)
506
- setCustomPath(part.path)
507
- }}
508
- className={`move-breadcrumb-btn ${targetPath === part.path && !customPath ? 'active' : ''}`}
509
- >
510
- {part.name}
511
- </button>
512
- </React.Fragment>
513
- ))}
514
- </div>
515
- <div className="move-folder-list">
516
- {allFolders.filter(f => {
517
- if (targetPath === '') {
518
- return !f.path.includes('/')
519
- }
520
- const prefix = targetPath + '/'
521
- if (!f.path.startsWith(prefix)) return false
522
- const relativePath = f.path.substring(prefix.length)
523
- return !relativePath.includes('/')
524
- }).length === 0 && (
525
- <p style={{ fontSize: 12, color: 'var(--text-secondary)', textAlign: 'center', padding: 16 }}>该目录下没有子文件夹</p>
526
- )}
527
- {allFolders.filter(f => {
528
- if (targetPath === '') {
529
- return !f.path.includes('/')
530
- }
531
- const prefix = targetPath + '/'
532
- if (!f.path.startsWith(prefix)) return false
533
- const relativePath = f.path.substring(prefix.length)
534
- return !relativePath.includes('/')
535
- }).map(folder => (
536
- <button
537
- key={folder.path}
538
- onClick={() => setTargetPath(folder.path)}
539
- className={`move-folder-item ${targetPath === folder.path && !customPath ? 'selected' : ''}`}
540
- >
541
- <Folder size={16} />
542
- <span>{folder.name}</span>
543
- </button>
544
- ))}
545
- </div>
546
- <div className="modal-actions">
547
- <button onClick={onClose} className="btn secondary">取消</button>
548
- <button
549
- onClick={handleConfirm}
550
- className="btn primary"
551
- >
552
- 移动
553
- </button>
554
- </div>
555
- </div>
556
- </ModalOverlay>
557
- )
558
- }
559
-
560
- // === 主应用 ===
561
- export default function App() {
562
- const [items, setItems] = useState([])
563
- const [trashItems, setTrashItems] = useState([])
564
- const [currentFolderId, setCurrentFolderId] = useState(null)
565
- const [currentView, setCurrentView] = useState('all')
566
- const [isDarkMode, setIsDarkMode] = useState(false)
567
- const [isDraggingOverUpload, setIsDraggingOverUpload] = useState(false)
568
- const [selectedIds, setSelectedIds] = useState([])
569
- const [previewItem, setPreviewItem] = useState(null)
570
- const [shareItem, setShareItem] = useState(null)
571
- const [isDownloadModalOpen, setIsDownloadModalOpen] = useState(false)
572
- const [downloadLink, setDownloadLink] = useState('')
573
- const [toasts, setToasts] = useState([])
574
- const [transfers, setTransfers] = useState([])
575
- const [isTransferPanelOpen, setIsTransferPanelOpen] = useState(false)
576
- const [searchQuery, setSearchQuery] = useState('')
577
- const [copied, setCopied] = useState(false)
578
- const [peerCount, setPeerCount] = useState(0)
579
- const [storageStats, setStorageStats] = useState({ total: 0, used: 0, free: 0 })
580
- const [isMoveModalOpen, setIsMoveModalOpen] = useState(false)
581
- const [confirmModal, setConfirmModal] = useState(null)
582
- const [inputModal, setInputModal] = useState(null)
583
- const [inputLoading, setInputLoading] = useState(false)
584
- const [renameTarget, setRenameTarget] = useState(null)
585
- const [showWelcome, setShowWelcome] = useState(() => !localStorage.getItem('mostbox_welcomed'))
586
- const [showSettings, setShowSettings] = useState(false)
587
- const [isSidebarOpen, setIsSidebarOpen] = useState(false)
588
- const [previewText, setPreviewText] = useState('')
589
- const [previewLoading, setPreviewLoading] = useState(false)
590
- const [previewMediaLoading, setPreviewMediaLoading] = useState(false)
591
- const [previewLoaded, setPreviewLoaded] = useState(false)
592
- const previewMediaRef = useRef(null)
593
- const previewTextRef = useRef(null)
594
-
595
- useEffect(() => {
596
- if (previewItem && (previewItem.subtype === 'image' || previewItem.subtype === 'video')) {
597
- setPreviewMediaLoading(true)
598
- setPreviewLoaded(false)
599
- }
600
- if (previewItem && previewItem.subtype === 'text') {
601
- setPreviewText('')
602
- loadPreviewText(previewItem.cid)
603
- }
604
- }, [previewItem?.cid])
605
-
606
- useEffect(() => {
607
- const media = previewMediaRef.current
608
- if (!media) return
609
-
610
- const handleLoad = () => setPreviewMediaLoading(false)
611
- const handleError = () => setPreviewMediaLoading(false)
612
-
613
- if (previewItem?.subtype === 'image') {
614
- if (media.complete) {
615
- setPreviewMediaLoading(false)
616
- } else {
617
- media.addEventListener('load', handleLoad)
618
- media.addEventListener('error', handleError)
619
- }
620
- } else if (previewItem?.subtype === 'video') {
621
- media.addEventListener('canplay', handleLoad)
622
- media.addEventListener('error', handleError)
623
- }
624
-
625
- return () => {
626
- media.removeEventListener('load', handleLoad)
627
- media.removeEventListener('error', handleError)
628
- media.removeEventListener('canplay', handleLoad)
629
- }
630
- }, [previewItem])
631
-
632
- const currentPath = currentFolderId || ''
633
- const allFolders = getUniqueFolders(items)
634
- const { folders, files } = getItemsForPath(items, allFolders, currentPath)
635
-
636
- const filteredFiles = searchQuery
637
- ? items.filter(f => parseName(f.fileName).name.toLowerCase().includes(searchQuery.toLowerCase()))
638
- : files
639
-
640
- const addToast = (message, type = 'info') => setToasts(prev => [...prev, { id: Date.now(), message, type }])
641
- const removeToast = (id) => setToasts(prev => prev.filter(t => t.id !== id))
642
-
643
- const refreshFiles = createRefreshHandler(setItems, () => API.listPublishedFiles().then(r => r || []))
644
- const refreshTrash = createRefreshHandler(setTrashItems, () => API.listTrashFiles().then(r => r || []))
645
- const refreshStorageStats = createRefreshHandler(setStorageStats, API.getStorageStats)
646
-
647
- const handleSelect = (id) => {
648
- setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])
649
- }
650
-
651
- const handleDelete = async (id) => {
652
- setConfirmModal({
653
- title: '确认删除',
654
- message: '确定要删除吗?',
655
- confirmText: '删除',
656
- danger: true,
657
- onConfirm: async () => {
658
- setConfirmModal(null)
659
- try {
660
- await API.deletePublishedFile(id)
661
- setSelectedIds(prev => prev.filter(i => i !== id))
662
- addToast('已删除', 'success')
663
- refreshFiles()
664
- refreshTrash()
665
- refreshStorageStats()
666
- } catch { addToast('删除失败', 'error') }
667
- }
668
- })
669
- }
670
-
671
- const handleFolderDelete = async (folder) => {
672
- const toDelete = items.filter(i => parseName(i.fileName).folder.toLowerCase() === folder.path.toLowerCase())
673
- setConfirmModal({
674
- title: '确认删除',
675
- message: toDelete.length > 0 ? `确定要删除文件夹中的 ${toDelete.length} 个文件吗?` : '确定要删除此文件夹吗?',
676
- confirmText: '删除',
677
- danger: true,
678
- onConfirm: async () => {
679
- setConfirmModal(null)
680
- try {
681
- for (const f of toDelete) { if (f.cid) await API.deletePublishedFile(f.cid) }
682
- addToast('已删除', 'success')
683
- refreshFiles()
684
- refreshTrash()
685
- refreshStorageStats()
686
- } catch { addToast('删除失败', 'error') }
687
- }
688
- })
689
- }
690
-
691
- const handlePermanentDelete = async (cid) => {
692
- setConfirmModal({
693
- title: '永久删除',
694
- message: '确定要永久删除吗?此操作不可恢复!',
695
- confirmText: '永久删除',
696
- danger: true,
697
- onConfirm: async () => {
698
- setConfirmModal(null)
699
- try {
700
- await API.permanentDeleteTrashFile(cid)
701
- addToast('已永久删除', 'success')
702
- refreshTrash()
703
- refreshStorageStats()
704
- } catch { addToast('删除失败', 'error') }
705
- }
706
- })
707
- }
708
-
709
- const handleRestore = async (cid) => {
710
- try {
711
- await API.restoreTrashFile(cid)
712
- addToast('已恢复', 'success')
713
- refreshFiles()
714
- refreshTrash()
715
- refreshStorageStats()
716
- } catch { addToast('恢复失败', 'error') }
717
- }
718
-
719
- const handleEmptyTrash = async () => {
720
- setConfirmModal({
721
- title: '清空回收站',
722
- message: '确定要清空回收站吗?此操作不可恢复!',
723
- confirmText: '清空',
724
- danger: true,
725
- onConfirm: async () => {
726
- setConfirmModal(null)
727
- try {
728
- await API.emptyTrash()
729
- addToast('回收站已清空', 'success')
730
- refreshTrash()
731
- refreshStorageStats()
732
- } catch { addToast('清空失败', 'error') }
733
- }
734
- })
735
- }
736
-
737
- const handleToggleStar = async (cid) => {
738
- try {
739
- const result = await API.toggleStar(cid)
740
- setItems(prev => prev.map(i => i.cid === cid ? { ...i, starred: result.starred } : i))
741
- addToast(result.starred ? '已收藏' : '已取消收藏', 'success')
742
- } catch { addToast('操作失败', 'error') }
743
- }
744
-
745
- const handleBatchDelete = async () => {
746
- const isTrash = currentView === 'trash'
747
- setConfirmModal({
748
- title: isTrash ? '永久删除' : '批量删除',
749
- message: isTrash ? `确定要永久删除选中的 ${selectedIds.length} 个项目吗?此操作不可恢复!` : `确定要删除选中的 ${selectedIds.length} 个项目吗?`,
750
- confirmText: isTrash ? '永久删除' : '删除',
751
- danger: true,
752
- onConfirm: async () => {
753
- setConfirmModal(null)
754
- try {
755
- for (const id of selectedIds) {
756
- if (isTrash) {
757
- await API.permanentDeleteTrashFile(id)
758
- } else {
759
- // 跳过文件夹 ID(以 '__' 前缀标识),只删除文件
760
- if (!id.startsWith('__')) await API.deletePublishedFile(id)
761
- }
762
- }
763
- setSelectedIds([])
764
- addToast(isTrash ? '已永久删除' : '已删除', 'success')
765
- refreshFiles()
766
- refreshTrash()
767
- refreshStorageStats()
768
- } catch { addToast('删除失败', 'error') }
769
- }
770
- })
771
- }
772
-
773
- const handleMove = async (targetPath) => {
774
- try {
775
- for (const id of selectedIds) {
776
- const file = items.find(i => i.cid === id)
777
- if (!file) continue
778
- const { name } = parseName(file.fileName)
779
- const newFileName = targetPath ? `${targetPath}/${name}` : name
780
- if (file.fileName !== newFileName) {
781
- await API.moveFile(id, newFileName)
782
- }
783
- }
784
- setSelectedIds([])
785
- setIsMoveModalOpen(false)
786
- addToast('已移动', 'success')
787
- refreshFiles()
788
- } catch { addToast('移动失败', 'error') }
789
- }
790
-
791
- const openRenameModal = (target) => {
792
- const isFolder = !!target.path
793
- const currentName = isFolder ? target.name : parseName(target.fileName).name
794
- setInputModal({
795
- title: isFolder ? '重命名文件夹' : '重命名文件',
796
- placeholder: '请输入新名称',
797
- defaultValue: currentName,
798
- confirmText: '重命名',
799
- onConfirm: async (newName) => {
800
- if (newName === currentName) return
801
- setInputLoading(true)
802
- try {
803
- if (isFolder) {
804
- const lastSlash = target.path.lastIndexOf('/')
805
- const parentPath = lastSlash !== -1 ? target.path.substring(0, lastSlash) : ''
806
- const newPath = parentPath ? `${parentPath}/${newName}` : newName
807
- await API.renameFolder(target.path, newPath)
808
- addToast('已重命名', 'success')
809
- refreshFiles()
810
- handleNavigate(newPath)
811
- } else {
812
- const { folder } = parseName(target.fileName)
813
- const newFileName = folder ? `${folder}/${newName}` : newName
814
- await API.moveFile(target.cid, newFileName)
815
- addToast('已重命名', 'success')
816
- refreshFiles()
817
- }
818
- setInputModal(null)
819
- } catch {
820
- addToast('重命名失败', 'error')
821
- } finally {
822
- setInputLoading(false)
823
- }
824
- }
825
- })
826
- }
827
-
828
- const processFiles = async (files) => {
829
- const prefix = currentPath ? currentPath + '/' : ''
830
- const newTransfers = []
831
-
832
- for (const file of Array.from(files)) {
833
- const fileName = prefix + file.name
834
-
835
- // 检查完整路径是否已存在
836
- const nameExists = items.some(item => item.fileName === fileName)
837
- if (nameExists) {
838
- addToast(`${file.name} 已存在`, 'warning')
839
- continue
840
- }
841
-
842
- // 创建传输条目用于进度跟踪
843
- const transferId = `upload_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
844
- const transfer = {
845
- id: transferId,
846
- fileName: file.name,
847
- progress: 0,
848
- type: 'upload',
849
- status: 'uploading'
850
- }
851
- newTransfers.push(transfer)
852
- setTransfers(prev => [...prev, transfer])
853
-
854
- // 有新传输时打开传输面板
855
- if (newTransfers.length > 0) {
856
- setIsTransferPanelOpen(true)
857
- }
858
-
859
- try {
860
- const result = await API.publishFile(file, fileName)
861
- if (result.alreadyExists) {
862
- // 更新传输状态
863
- setTransfers(prev => prev.map(t =>
864
- t.id === transferId ? { ...t, status: 'completed' } : t
865
- ))
866
- addToast(`${file.name} 已存在`, 'warning')
867
- } else {
868
- // 更新传输状态
869
- setTransfers(prev => prev.map(t =>
870
- t.id === transferId ? { ...t, progress: 100, status: 'completed' } : t
871
- ))
872
- addToast(`${file.name} 上传成功`, 'success')
873
- }
874
- } catch (err) {
875
- setTransfers(prev => prev.map(t =>
876
- t.id === transferId ? { ...t, status: 'error' } : t
877
- ))
878
- addToast(`上传失败: ${file.name}`, 'error')
879
- }
880
- }
881
-
882
- // 延迟移除已完成的传输
883
- setTimeout(() => {
884
- setTransfers(prev => prev.filter(t => t.status !== 'completed' && t.status !== 'error' && t.status !== 'cancelled'))
885
- }, 3000)
886
-
887
- refreshFiles()
888
- refreshStorageStats()
889
- }
890
-
891
- const loadPreviewText = async (cid) => {
892
- setPreviewLoading(true)
893
- try {
894
- const res = await fetch(`/api/files/${cid}/download`, {
895
- headers: { 'Range': 'bytes=0-9999' }
896
- })
897
- if (!res.ok) throw new Error('加载失败')
898
- const text = await res.text()
899
- setPreviewText(text || '(文件为空)')
900
- } catch {
901
- setPreviewText('加载失败')
902
- }
903
- setPreviewLoading(false)
904
- }
905
-
906
- const handleCopyLink = () => {
907
- navigator.clipboard.writeText(`most://${shareItem.cid}`).then(() => {
908
- setCopied(true)
909
- setTimeout(() => setCopied(false), 2000)
910
- })
911
- }
912
-
913
- const [isDownloading, setIsDownloading] = useState(false)
914
-
915
- const handleDownloadSharedFile = async () => {
916
- if (!downloadLink.trim() || !downloadLink.startsWith('most://')) {
917
- addToast('链接格式应为 most://<cid>', 'warning')
918
- return
919
- }
920
- if (isDownloading) return
921
- setIsDownloading(true)
922
- try {
923
- const result = await API.downloadFile(downloadLink)
924
- setDownloadLink('')
925
- setIsDownloadModalOpen(false)
926
-
927
- if (result.alreadyExists) {
928
- addToast(`${result.fileName} 已存在`, 'warning')
929
- } else {
930
- const transfer = {
931
- id: result.taskId,
932
- fileName: '下载文件',
933
- progress: 0,
934
- type: 'download',
935
- status: 'downloading'
936
- }
937
- setTransfers(prev => [...prev, transfer])
938
- setIsTransferPanelOpen(true)
939
- addToast('下载已开始', 'info')
940
- }
941
- } catch (err) {
942
- addToast('下载失败', 'error')
943
- } finally {
944
- setIsDownloading(false)
945
- }
946
- }
947
-
948
- const handleCancelTransfer = async (transfer) => {
949
- if (transfer.type === 'download' && transfer.status === 'downloading') {
950
- try {
951
- await API.cancelDownload(transfer.id)
952
- // WebSocket 会处理 'download:cancelled' 事件
953
- } catch (err) {
954
- addToast('取消失败', 'error')
955
- }
956
- }
957
- }
958
-
959
- const handleSaveAs = async (file) => {
960
- try {
961
- const res = await fetch(API.getFileDownloadUrl(file.cid))
962
- if (!res.ok) throw new Error('获取文件失败')
963
- const blob = await res.blob()
964
- if (window.showSaveFilePicker) {
965
- const handle = await window.showSaveFilePicker({
966
- suggestedName: file.fileName
967
- })
968
- const writable = await handle.createWritable()
969
- await writable.write(blob)
970
- await writable.close()
971
- addToast('文件已保存', 'success')
972
- } else {
973
- const url = URL.createObjectURL(blob)
974
- const a = document.createElement('a')
975
- a.href = url
976
- a.download = file.fileName
977
- document.body.appendChild(a)
978
- a.click()
979
- document.body.removeChild(a)
980
- URL.revokeObjectURL(url)
981
- addToast('文件已下载', 'success')
982
- }
983
- } catch (err) {
984
- if (err.name !== 'AbortError') {
985
- addToast('保存失败: ' + err.message, 'error')
986
- }
987
- }
988
- }
989
-
990
- const handleNavigate = (path) => {
991
- setCurrentFolderId(path || null)
992
- setSelectedIds([])
993
- }
994
-
995
- const handleCloseWelcome = () => {
996
- setShowWelcome(false)
997
- localStorage.setItem('mostbox_welcomed', 'true')
998
- }
999
-
1000
- const handleShutdown = () => {
1001
- setConfirmModal({
1002
- title: '关闭服务',
1003
- message: '确定要关闭服务吗?',
1004
- confirmText: '关闭',
1005
- danger: true,
1006
- onConfirm: async () => {
1007
- setConfirmModal(null)
1008
- try {
1009
- await fetch('/api/shutdown', { method: 'POST' })
1010
- } catch { }
1011
- window.close()
1012
- }
1013
- })
1014
- }
1015
-
1016
- // WebSocket 连接
1017
- useEffect(() => {
1018
- const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
1019
- const ws = new WebSocket(`${protocol}//${location.host}/ws`)
1020
- ws.onmessage = (e) => {
1021
- try {
1022
- const { event, data } = JSON.parse(e.data)
1023
- if (event === 'publish:success' || event === 'download:success') {
1024
- refreshFiles()
1025
- refreshStorageStats()
1026
- const taskId = data.taskId || data.fileName
1027
- setTransfers(prev => prev.map(t =>
1028
- (t.id === taskId || t.fileName === data.fileName) ? { ...t, progress: 100, status: 'completed' } : t
1029
- ))
1030
- if (event === 'download:success') {
1031
- if (data.alreadyExists) {
1032
- addToast(`${data.fileName} 已存在`, 'warning')
1033
- } else {
1034
- addToast(`${data.fileName} 下载完成`, 'success')
1035
- }
1036
- // 延迟移除已完成的下载
1037
- setTimeout(() => {
1038
- setTransfers(prev => prev.filter(t => !(t.id === taskId && t.status === 'completed')))
1039
- }, 3000)
1040
- }
1041
- }
1042
- // 处理发布/上传进度
1043
- if (event === 'publish:progress') {
1044
- setTransfers(prev => prev.map(t => {
1045
- if (data.file && t.fileName === data.file && t.type === 'upload') {
1046
- // 根据阶段计算百分比
1047
- let progress = 50
1048
- if (data.stage === 'calculating-cid') progress = 25
1049
- else if (data.stage === 'uploading') progress = 75
1050
- else if (data.stage === 'complete') progress = 100
1051
- return { ...t, progress }
1052
- }
1053
- return t
1054
- }))
1055
- }
1056
- // 处理下载进度
1057
- if (event === 'download:progress') {
1058
- setTransfers(prev => prev.map(t =>
1059
- t.id === data.taskId ? { ...t, progress: data.percent || 0, loaded: data.loaded, total: data.total } : t
1060
- ))
1061
- }
1062
- // 处理下载错误
1063
- if (event === 'download:error') {
1064
- setTransfers(prev => prev.map(t =>
1065
- t.id === data.taskId ? { ...t, status: 'error' } : t
1066
- ))
1067
- addToast(`下载失败: ${data.error}`, 'error')
1068
- }
1069
- // 处理下载状态(包含文件名)
1070
- if (event === 'download:status') {
1071
- setTransfers(prev => prev.map(t =>
1072
- t.id === data.taskId ? { ...t, fileName: data.file || t.fileName } : t
1073
- ))
1074
- }
1075
- // 处理下载取消
1076
- if (event === 'download:cancelled') {
1077
- setTransfers(prev => prev.map(t =>
1078
- t.id === data.taskId ? { ...t, status: 'cancelled' } : t
1079
- ))
1080
- addToast('下载已取消', 'warning')
1081
- }
1082
- } catch { }
1083
- }
1084
- return () => ws.close()
1085
- }, [])
1086
-
1087
- // 初始化
1088
- useEffect(() => {
1089
- const saved = localStorage.getItem('theme')
1090
- if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
1091
- setIsDarkMode(true)
1092
- }
1093
- refreshFiles()
1094
- refreshTrash()
1095
- refreshStorageStats()
1096
- API.getStorageStats().then(s => setStorageStats(s)).catch(() => { })
1097
- }, [])
1098
-
1099
- // 同步 data-theme 属性
1100
- useEffect(() => {
1101
- document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light')
1102
- }, [isDarkMode])
1103
-
1104
- // 主题颜色
1105
- const viewTitle = currentView === 'all' ? '全部内容' : currentView === 'starred' ? '收藏' : '回收站'
1106
- const displayFiles = currentView === 'all'
1107
- ? filteredFiles
1108
- : currentView === 'starred'
1109
- ? items.filter(i => i.starred && parseName(i.fileName).name.toLowerCase().includes(searchQuery.toLowerCase()))
1110
- : trashItems.filter(i => parseName(i.fileName).name.toLowerCase().includes(searchQuery.toLowerCase()))
1111
- const displayFolders = currentView === 'starred'
1112
- ? []
1113
- : folders.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase()))
1114
-
1115
- // 面包屑部分
1116
- const breadcrumbParts = generateBreadcrumbs(currentPath)
1117
-
1118
- return (
1119
- <div className="app-layout">
1120
- {/* 侧边栏遮罩 */}
1121
- <div className={`sidebar-overlay ${isSidebarOpen ? 'visible' : ''}`} onClick={() => setIsSidebarOpen(false)} />
1122
-
1123
- {/* 侧边栏 */}
1124
- <div className={`sidebar ${isSidebarOpen ? 'open' : ''}`}>
1125
- <div className="sidebar-header">
1126
- <h1>Most.Box</h1>
1127
- </div>
1128
- <nav className="sidebar-nav">
1129
- {[{ id: 'all', icon: <Files size={18} />, label: '全部内容' }, { id: 'starred', icon: <Star size={18} />, label: '收藏' }, { id: 'trash', icon: <Trash2 size={18} />, label: '回收站' }].map(item => (
1130
- <button
1131
- key={item.id}
1132
- onClick={() => { setCurrentView(item.id); setCurrentFolderId(null); setSelectedIds([]); setSearchQuery(''); setIsSidebarOpen(false) }}
1133
- className={`sidebar-nav-btn ${currentView === item.id ? 'active' : ''}`}
1134
- >
1135
- {item.icon}
1136
- <span>{item.label}</span>
1137
- </button>
1138
- ))}
1139
- </nav>
1140
- <div className="sidebar-footer">
1141
- <div className="sidebar-footer-label">
1142
- <HardDrive size={14} />
1143
- <span>存储空间</span>
1144
- </div>
1145
- <div className="storage-bar">
1146
- <div className="storage-bar-fill" style={{ width: `${storageStats.total > 0 ? (storageStats.used / storageStats.total) * 100 : 0}%` }} />
1147
- </div>
1148
- <div className="storage-info">
1149
- <span>{formatSize(storageStats.used)}</span>
1150
- <span>{storageStats.total > 0 ? formatSize(storageStats.total) : '-'}</span>
1151
- </div>
1152
- </div>
1153
- </div>
1154
-
1155
- {/* 主内容区 */}
1156
- <div className="main-content">
1157
- {/* 头部 */}
1158
- <header className="app-header">
1159
- <div className="header-left">
1160
- <button onClick={() => setIsSidebarOpen(true)} className="icon-btn mobile-menu-btn">
1161
- <Menu size={18} />
1162
- </button>
1163
- <h2 className="header-title">{viewTitle}</h2>
1164
- <div className="header-badge">
1165
- <div className={`header-badge-dot ${peerCount > 0 ? 'connected' : ''}`} />
1166
- {peerCount > 0 ? `${peerCount} 节点` : '等待连接'}
1167
- </div>
1168
- </div>
1169
- <div className="header-right">
1170
- <div className="search-box">
1171
- <Search size={14} />
1172
- <input type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索..." />
1173
- {searchQuery && <button onClick={() => setSearchQuery('')}><X size={12} /></button>}
1174
- </div>
1175
- {currentView === 'trash' && trashItems.length > 0 && (
1176
- <button onClick={handleEmptyTrash} className="btn small" style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}>
1177
- 清空回收站
1178
- </button>
1179
- )}
1180
- <button onClick={() => setIsTransferPanelOpen(true)} className="icon-btn">
1181
- <ArrowUpDown size={16} />
1182
- {transfers.length > 0 && <span className="icon-btn-badge">{transfers.length}</span>}
1183
- </button>
1184
- <button onClick={() => setIsDarkMode(!isDarkMode)} className="icon-btn theme-toggle">
1185
- {isDarkMode ? <Sun size={16} /> : <Moon size={16} />}
1186
- </button>
1187
- <button onClick={() => setShowSettings(true)} className="icon-btn">
1188
- <Info size={16} />
1189
- </button>
1190
- </div>
1191
- </header>
1192
-
1193
- {/* 上传/下载 */}
1194
- {currentView === 'all' && (
1195
- <div className="action-grid">
1196
- <div className={`action-card upload ${isDraggingOverUpload ? 'drag-over' : ''}`} onDragOver={(e) => { e.preventDefault(); setIsDraggingOverUpload(true) }} onDragLeave={() => setIsDraggingOverUpload(false)} onDrop={(e) => { e.preventDefault(); setIsDraggingOverUpload(false); processFiles(e.dataTransfer.files) }}>
1197
- <input type="file" multiple onChange={(e) => processFiles(e.target.files)} className="action-card-input" />
1198
- <Upload size={20} style={{ marginBottom: 8 }} />
1199
- <p>上传文件</p>
1200
- </div>
1201
- <div className="action-card action-card-download" onClick={() => setIsDownloadModalOpen(true)}>
1202
- <Download size={20} style={{ marginBottom: 8 }} />
1203
- <p>下载文件</p>
1204
- </div>
1205
- </div>
1206
- )}
1207
-
1208
- {/* 面包屑 */}
1209
- {currentView === 'all' && (
1210
- <div className="breadcrumb">
1211
- {currentPath ? (
1212
- <>
1213
- <button onClick={() => handleNavigate('')}>全部内容</button>
1214
- {breadcrumbParts.slice(1).map((part, i) => (
1215
- <React.Fragment key={part.path}>
1216
- <ChevronRight size={12} />
1217
- <button onClick={() => handleNavigate(part.path)} className={i === breadcrumbParts.length - 2 ? 'current' : ''}>{part.name}</button>
1218
- {i === breadcrumbParts.length - 2 && (
1219
- <button onClick={() => openRenameModal(part)} className="breadcrumb-edit-btn">
1220
- <Edit2 size={12} />
1221
- </button>
1222
- )}
1223
- </React.Fragment>
1224
- ))}
1225
- </>
1226
- ) : null}
1227
- </div>
1228
- )}
1229
-
1230
- {/* 内容网格 */}
1231
- <div className="content-grid">
1232
- {/* 回收站视图 */}
1233
- {currentView === 'trash' && (
1234
- displayFiles.length === 0 ? (
1235
- <div className="empty-state">{searchQuery ? '未找到相关文件' : '回收站是空的'}</div>
1236
- ) : (
1237
- <div className="file-grid">
1238
- {displayFiles.map(f => (
1239
- <div
1240
- key={f.cid}
1241
- onClick={() => setSelectedIds(prev => prev.includes(f.cid) ? prev.filter(id => id !== f.cid) : [...prev, f.cid])}
1242
- onDoubleClick={() => handleRestore(f.cid)}
1243
- className={`card ${selectedIds.includes(f.cid) ? 'selected' : ''}`}
1244
- >
1245
- <div className="card-icon trash">
1246
- <FileText size={24} color="#fff" />
1247
- </div>
1248
- <p className="card-name">{parseName(f.fileName).name}</p>
1249
- <p className="card-date">删除于 {formatDate(f.deletedAt)}</p>
1250
- </div>
1251
- ))}
1252
- </div>
1253
- )
1254
- )}
1255
-
1256
- {/* 全部/收藏视图 */}
1257
- {currentView !== 'trash' && (
1258
- displayFiles.length === 0 && displayFolders.length === 0 ? (
1259
- <div className="empty-state">
1260
- {searchQuery ? '未找到相关文件' : (currentView === 'starred' ? '暂无收藏' : '暂无文件')}
1261
- </div>
1262
- ) : (
1263
- <div className="file-grid">
1264
- {displayFolders.map(folder => (
1265
- <FolderCard key={folder.path} folder={folder} isDarkMode={isDarkMode} onClick={() => handleNavigate(folder.path)} />
1266
- ))}
1267
- {displayFiles.map(f => (
1268
- <FileCard key={f.cid} file={f} isSelected={selectedIds.includes(f.cid)} isDarkMode={isDarkMode} onSelect={handleSelect} onPreview={(file) => setPreviewItem({ ...file, subtype: getFileSubtype(file.fileName) })} />
1269
- ))}
1270
- </div>
1271
- )
1272
- )}
1273
- </div>
1274
- </div>
1275
-
1276
-
1277
-
1278
- {/* 确认弹窗 */}
1279
- {confirmModal && (
1280
- <ConfirmModal
1281
- title={confirmModal.title}
1282
- message={confirmModal.message}
1283
- confirmText={confirmModal.confirmText}
1284
- danger={confirmModal.danger}
1285
- isDarkMode={isDarkMode}
1286
- closeOnOverlayClick={confirmModal.danger}
1287
- onConfirm={confirmModal.onConfirm}
1288
- onClose={() => setConfirmModal(null)}
1289
- />
1290
- )}
1291
-
1292
- {/* 输入弹窗 */}
1293
- {inputModal && (
1294
- <InputModal
1295
- title={inputModal.title}
1296
- placeholder={inputModal.placeholder}
1297
- defaultValue={inputModal.defaultValue}
1298
- confirmText={inputModal.confirmText}
1299
- isDarkMode={isDarkMode}
1300
- isLoading={inputLoading}
1301
- onConfirm={inputModal.onConfirm}
1302
- onClose={() => setInputModal(null)}
1303
- />
1304
- )}
1305
-
1306
- {/* 移动弹窗 */}
1307
- {isMoveModalOpen && (
1308
- <MoveModal
1309
- items={selectedIds.map(id => items.find(i => i.cid === id)).filter(Boolean)}
1310
- allFolders={allFolders.map(path => ({ path, name: path.split('/').pop() }))}
1311
- currentPath={currentPath}
1312
- isDarkMode={isDarkMode}
1313
- onMove={handleMove}
1314
- onClose={() => setIsMoveModalOpen(false)}
1315
- />
1316
- )}
1317
-
1318
- {/* 分享弹窗 */}
1319
- {shareItem && (
1320
- <ModalOverlay onClose={() => setShareItem(null)}>
1321
- <div className="share-modal" onClick={e => e.stopPropagation()}>
1322
- <div className="modal-header">
1323
- <h3>分享链接</h3>
1324
- <button onClick={() => setShareItem(null)} className="modal-close-btn"><X size={18} /></button>
1325
- </div>
1326
- <div className="share-link-box">
1327
- <div className="share-link-text">most://{shareItem.cid}</div>
1328
- <button onClick={handleCopyLink} className={`share-copy-btn ${copied ? 'copied' : ''}`}>
1329
- {copied ? <Check size={18} /> : <Copy size={18} />}
1330
- </button>
1331
- </div>
1332
- </div>
1333
- </ModalOverlay>
1334
- )}
1335
-
1336
- {/* 下载弹窗 */}
1337
- {isDownloadModalOpen && (
1338
- <ModalOverlay onClose={() => setIsDownloadModalOpen(false)}>
1339
- <div className="download-modal" onClick={e => e.stopPropagation()}>
1340
- <div className="modal-header">
1341
- <h3>下载文件</h3>
1342
- <button onClick={() => setIsDownloadModalOpen(false)} className="modal-close-btn"><X size={18} /></button>
1343
- </div>
1344
- <input type="text" value={downloadLink} onChange={(e) => setDownloadLink(e.target.value)} placeholder="most://..." onKeyDown={(e) => e.key === 'Enter' && handleDownloadSharedFile()} className="download-input" />
1345
- <button onClick={handleDownloadSharedFile} disabled={!downloadLink.trim() || isDownloading} className="download-btn">
1346
- {isDownloading ? '下载中...' : '开始下载'}
1347
- </button>
1348
- </div>
1349
- </ModalOverlay>
1350
- )}
1351
-
1352
- {/* 预览弹窗 */}
1353
- {previewItem && (
1354
- <div className="preview-overlay" onClick={() => { setPreviewItem(null); setPreviewText(''); setPreviewMediaLoading(false) }}>
1355
- <button className="preview-close"><X size={20} /></button>
1356
- <div onClick={e => e.stopPropagation()}>
1357
- {previewItem.subtype === 'image' && (
1358
- <div className="preview-media-wrapper">
1359
- <img ref={previewMediaRef} src={API.getFileDownloadUrl(previewItem.cid)} alt="" />
1360
- </div>
1361
- )}
1362
- {previewItem.subtype === 'video' && (
1363
- <div className="preview-media-wrapper">
1364
- <video ref={previewMediaRef} src={API.getFileDownloadUrl(previewItem.cid)} controls />
1365
- </div>
1366
- )}
1367
- {previewItem.subtype === 'audio' && (
1368
- <div className="preview-audio">
1369
- <div className="preview-audio-icon">
1370
- <Music size={36} color="var(--accent-blue)" />
1371
- </div>
1372
- <p className="preview-audio-filename">{previewItem.fileName}</p>
1373
- <audio className="preview-audio-player" src={API.getFileDownloadUrl(previewItem.cid)} controls />
1374
- </div>
1375
- )}
1376
- {previewItem.subtype === 'file' && (
1377
- <div className="preview-unsupported">
1378
- <FileText size={48} color="#fff" style={{ marginBottom: 12, opacity: 0.5 }} />
1379
- <p>{previewItem.fileName}</p>
1380
- <p style={{ fontSize: 12, marginTop: 8, opacity: 0.7 }}>无法预览</p>
1381
- </div>
1382
- )}
1383
- {previewItem.subtype === 'text' && (
1384
- <div className="preview-text-container">
1385
- <div className="preview-text-header">
1386
- <span>{previewItem.fileName}</span>
1387
- </div>
1388
- {previewLoading ? (
1389
- <div className="preview-text-loading">
1390
- <Loader size={24} className="preview-text-spinner" />
1391
- <p>正在加载文本预览...</p>
1392
- <p className="preview-text-loading-hint">如果是首次预览,可能需要等待 P2P 网络同步</p>
1393
- </div>
1394
- ) : (
1395
- <pre className="preview-text">{previewText || '(文件为空)'}</pre>
1396
- )}
1397
- </div>
1398
- )}
1399
- </div>
1400
- </div>
1401
- )}
1402
-
1403
- {/* 批量操作栏 */}
1404
- {selectedIds.length > 0 && (
1405
- <div className="batch-bar">
1406
- <span className="batch-info">已选 {selectedIds.length} 项</span>
1407
- <button onClick={() => setSelectedIds([])} className="batch-dismiss"><X size={16} /></button>
1408
- <div className="batch-divider" />
1409
- {currentView === 'trash' ? (
1410
- <>
1411
- <button onClick={async () => {
1412
- await Promise.all(selectedIds.map(cid => API.restoreTrashFile(cid)))
1413
- setSelectedIds([])
1414
- addToast('已恢复', 'success')
1415
- refreshFiles()
1416
- refreshTrash()
1417
- refreshStorageStats()
1418
- }} className="btn small">
1419
- 恢复
1420
- </button>
1421
- <button onClick={handleBatchDelete} className="btn small danger">
1422
- 永久删除
1423
- </button>
1424
- </>
1425
- ) : (
1426
- <>
1427
- {selectedIds.length === 1 && (() => {
1428
- const file = items.find(i => i.cid === selectedIds[0])
1429
- return file && getFileSubtype(file.fileName) !== 'file'
1430
- })() && (
1431
- <button onClick={() => {
1432
- const file = items.find(i => i.cid === selectedIds[0])
1433
- if (file) {
1434
- const subtype = getFileSubtype(file.fileName)
1435
- setPreviewItem({ ...file, subtype })
1436
- setPreviewText('')
1437
- if (subtype === 'text') loadPreviewText(file.cid)
1438
- }
1439
- }} className="btn small">
1440
- 预览
1441
- </button>
1442
- )}
1443
- <button onClick={() => {
1444
- const hasUnstarred = selectedIds.some(id => {
1445
- const item = items.find(i => i.cid === id)
1446
- return item && !item.starred
1447
- })
1448
- selectedIds.forEach(id => {
1449
- const item = items.find(i => i.cid === id)
1450
- if (item && (hasUnstarred ? !item.starred : item.starred)) {
1451
- handleToggleStar(id)
1452
- }
1453
- })
1454
- }} className="btn small" style={{ background: '#f59e0b', color: '#fff' }}>
1455
- 收藏
1456
- </button>
1457
- {selectedIds.length === 1 && (
1458
- <button onClick={() => {
1459
- const firstSelected = items.find(i => i.cid === selectedIds[0])
1460
- if (firstSelected) openRenameModal(firstSelected)
1461
- }} className="btn small">
1462
- 重命名
1463
- </button>
1464
- )}
1465
- <button onClick={() => setIsMoveModalOpen(true)} className="btn small" style={{ background: 'var(--accent-blue)', color: '#fff' }}>
1466
- 移动
1467
- </button>
1468
- <button onClick={handleBatchDelete} className="btn small danger">删除</button>
1469
- {selectedIds.length === 1 && (
1470
- <button onClick={() => setShareItem(items.find(i => i.cid === selectedIds[0]))} className="btn small">分享</button>
1471
- )}
1472
- {selectedIds.length === 1 && (
1473
- <button onClick={() => {
1474
- const file = items.find(i => i.cid === selectedIds[0])
1475
- if (file) handleSaveAs(file)
1476
- }} className="btn small">另存为</button>
1477
- )}
1478
- </>
1479
- )}
1480
- </div>
1481
- )}
1482
-
1483
- {/* 传输面板 */}
1484
- {isTransferPanelOpen && (
1485
- <ModalOverlay onClose={() => setIsTransferPanelOpen(false)} closeOnOverlayClick={true}>
1486
- <div className="transfer-modal" onClick={e => e.stopPropagation()}>
1487
- <div className="modal-header">
1488
- <h3>传输</h3>
1489
- <button onClick={() => setIsTransferPanelOpen(false)} className="modal-close-btn"><X size={18} /></button>
1490
- </div>
1491
- {transfers.length === 0 ? (
1492
- <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 24, fontSize: 13 }}>
1493
- 暂无传输
1494
- </div>
1495
- ) : (
1496
- transfers.map(t => (
1497
- <div key={t.id} className="transfer-item">
1498
- <div className="transfer-item-header">
1499
- {t.type === 'upload' ? <Upload size={14} /> : <Download size={14} />}
1500
- <span className="transfer-item-name">{t.fileName}</span>
1501
- {t.status === 'downloading' && t.type === 'download' && (
1502
- <button onClick={() => handleCancelTransfer(t)} className="transfer-item-cancel">
1503
- <X size={14} />
1504
- </button>
1505
- )}
1506
- </div>
1507
- <div className="transfer-progress-row">
1508
- <div className="transfer-progress-bar">
1509
- <div
1510
- className={`transfer-progress-fill ${t.type === 'download' ? 'download' : ''} ${t.status === 'error' ? 'error' : ''} ${t.status === 'cancelled' ? 'cancelled' : ''}`}
1511
- style={{ width: `${t.progress}%` }}
1512
- />
1513
- </div>
1514
- <span className="transfer-progress-text">
1515
- {t.status === 'completed' ? '完成' :
1516
- t.status === 'error' ? '失败' :
1517
- t.status === 'cancelled' ? '已取消' :
1518
- t.loaded && t.total ? `${formatSize(t.loaded)}/${formatSize(t.total)}` :
1519
- `${t.progress}%`}
1520
- </span>
1521
- </div>
1522
- </div>
1523
- ))
1524
- )}
1525
- </div>
1526
- </ModalOverlay>
1527
- )}
1528
-
1529
- {/* 通知列表 */}
1530
- {toasts.map((t, i) => <Toast key={t.id} message={t.message} type={t.type} onDone={() => removeToast(t.id)} index={i} />)}
1531
-
1532
- {/* 引导页 */}
1533
- {showWelcome && <WelcomeGuide onClose={handleCloseWelcome} onShutdown={() => {
1534
- fetch('/api/shutdown', { method: 'POST' })
1535
- addToast('服务已关闭,请重新启动应用', 'info')
1536
- handleCloseWelcome()
1537
- }} />}
1538
-
1539
- {/* 设置弹窗 */}
1540
- {showSettings && <SettingsModal onClose={() => setShowSettings(false)} addToast={addToast} isDarkMode={isDarkMode} handleShutdown={handleShutdown} />}
1541
- </div>
1542
- )
1543
- }