most-box 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -73
- package/cli.js +2 -2
- package/package.json +9 -5
- package/public/app.css +1519 -0
- package/public/app.jsx +607 -399
- package/public/bundle.css +1 -0
- package/public/bundle.js +10 -14
- package/public/error-boundary.jsx +50 -0
- package/public/index.html +2 -1
- package/public/index.jsx +16 -1
- package/server.js +280 -197
- package/src/config.js +24 -7
- package/src/core/cid.js +23 -18
- package/src/index.js +400 -272
- package/src/utils/security.js +27 -24
- package/public/bundle.js.map +0 -7
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/mask-icon.svg +0 -3
- package/public/icons/most.png +0 -0
- package/public/icons/pwa-192x192.png +0 -0
- package/public/icons/pwa-512x512.png +0 -0
package/public/app.jsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
Upload, Sun, Moon, Image as ImageIcon, Trash2, Folder,
|
|
4
|
-
|
|
4
|
+
Film, Music, ChevronRight, FileText,
|
|
5
5
|
X, Check, Copy, Download, ArrowUpDown, Star, Files, HardDrive, Search, Info,
|
|
6
|
-
FolderOpen, Power
|
|
6
|
+
FolderOpen, Power, Edit2, Menu, Eye, Loader
|
|
7
7
|
} from 'lucide-react'
|
|
8
8
|
|
|
9
|
-
// ===
|
|
9
|
+
// === 接口 ===
|
|
10
10
|
const API = {
|
|
11
11
|
async fetch(url, options = {}) {
|
|
12
12
|
const res = await fetch(url, options)
|
|
@@ -64,7 +64,7 @@ const API = {
|
|
|
64
64
|
})
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// ===
|
|
67
|
+
// === 工具函数 ===
|
|
68
68
|
function formatSize(bytes) {
|
|
69
69
|
if (!bytes || bytes <= 0) return '0 B'
|
|
70
70
|
if (bytes < 1024) return `${bytes} B`
|
|
@@ -119,41 +119,135 @@ function getFileSubtype(fileName) {
|
|
|
119
119
|
const imgExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'heic', 'heif']
|
|
120
120
|
const vidExts = ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'm4v', 'mpeg', '3gp']
|
|
121
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']
|
|
122
123
|
if (imgExts.includes(ext)) return 'image'
|
|
123
124
|
if (vidExts.includes(ext)) return 'video'
|
|
124
125
|
if (audExts.includes(ext)) return 'audio'
|
|
126
|
+
if (txtExts.includes(ext)) return 'text'
|
|
125
127
|
return 'file'
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
// ===
|
|
129
|
-
function WelcomeGuide({ onClose }) {
|
|
130
|
+
// === 引导页 ===
|
|
131
|
+
function WelcomeGuide({ onClose, onShutdown }) {
|
|
130
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
|
+
|
|
131
144
|
const steps = [
|
|
132
145
|
{ title: '欢迎使用', content: '拖拽文件到上传区,或点击选择文件。上传后复制链接发给朋友即可。' },
|
|
133
|
-
{ title: '下载文件', content: '点击「下载文件」,粘贴分享链接即可从 P2P 网络下载文件。' }
|
|
146
|
+
{ title: '下载文件', content: '点击「下载文件」,粘贴分享链接即可从 P2P 网络下载文件。' },
|
|
147
|
+
{ title: '设置存储位置', content: '选择文件存储的文件夹位置(可选,默认使用系统盘)', isOptional: true }
|
|
134
148
|
]
|
|
135
149
|
const current = steps[step]
|
|
136
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
|
+
|
|
137
168
|
return (
|
|
138
|
-
<ModalOverlay onClose={onClose}>
|
|
139
|
-
<div
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
)}
|
|
150
244
|
</div>
|
|
151
245
|
</ModalOverlay>
|
|
152
246
|
)
|
|
153
247
|
}
|
|
154
248
|
|
|
155
|
-
// ===
|
|
156
|
-
function SettingsModal({ onClose, addToast }) {
|
|
249
|
+
// === 设置弹窗 ===
|
|
250
|
+
function SettingsModal({ onClose, addToast, isDarkMode, handleShutdown }) {
|
|
157
251
|
const [dataPath, setStoragePath] = useState('')
|
|
158
252
|
const [originalPath, setOriginalPath] = useState('')
|
|
159
253
|
const [isDefault, setIsDefault] = useState(false)
|
|
@@ -201,93 +295,78 @@ function SettingsModal({ onClose, addToast }) {
|
|
|
201
295
|
|
|
202
296
|
return (
|
|
203
297
|
<ModalOverlay onClose={onClose}>
|
|
204
|
-
<div
|
|
205
|
-
<div
|
|
206
|
-
<h2
|
|
207
|
-
<button onClick={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>
|
|
208
302
|
</div>
|
|
209
303
|
|
|
210
304
|
<div style={{ marginBottom: 20 }}>
|
|
211
|
-
<label
|
|
212
|
-
<div
|
|
305
|
+
<label className="settings-label">存储位置</label>
|
|
306
|
+
<div className="settings-row">
|
|
213
307
|
<input
|
|
214
308
|
type="text"
|
|
215
309
|
value={dataPath}
|
|
216
310
|
onChange={(e) => setStoragePath(e.target.value)}
|
|
217
|
-
placeholder="
|
|
311
|
+
placeholder="如 D:\most-data"
|
|
218
312
|
disabled={loading}
|
|
219
|
-
|
|
313
|
+
className="settings-input"
|
|
220
314
|
/>
|
|
221
|
-
<button onClick={handleSavePath} disabled={saving || loading || !isPathChanged} style={{
|
|
315
|
+
<button onClick={handleSavePath} disabled={saving || loading || !isPathChanged} className="btn primary" style={{ whiteSpace: 'nowrap', opacity: saving || loading || !isPathChanged ? 0.5 : 1 }}>
|
|
222
316
|
{saving ? '保存中...' : '保存'}
|
|
223
317
|
</button>
|
|
224
318
|
{!isDefault && (
|
|
225
|
-
<button onClick={handleResetPath} disabled={saving || loading} style={{
|
|
319
|
+
<button onClick={handleResetPath} disabled={saving || loading} className="btn secondary" style={{ whiteSpace: 'nowrap', opacity: saving || loading ? 0.5 : 1 }}>
|
|
226
320
|
恢复默认
|
|
227
321
|
</button>
|
|
228
322
|
)}
|
|
229
323
|
</div>
|
|
230
|
-
<p
|
|
324
|
+
<p className="settings-hint">修改后需重启应用</p>
|
|
231
325
|
</div>
|
|
232
326
|
|
|
233
|
-
<div
|
|
234
|
-
<div
|
|
235
|
-
<h3
|
|
236
|
-
<p
|
|
327
|
+
<div className="settings-divider">
|
|
328
|
+
<div className="settings-about">
|
|
329
|
+
<h3>MostBox</h3>
|
|
330
|
+
<p>版本 0.0.1</p>
|
|
237
331
|
</div>
|
|
238
|
-
<p style={{ fontSize: 12,
|
|
332
|
+
<p style={{ fontSize: 12, textAlign: 'center', color: 'var(--text-secondary)' }}>Hyperswarm · Hyperdrive · IPFS</p>
|
|
239
333
|
</div>
|
|
240
334
|
|
|
241
|
-
<button onClick={onClose} style={{
|
|
242
|
-
|
|
335
|
+
<button onClick={() => { onClose(); handleShutdown(); }} className="btn danger full" style={{ marginTop: 20 }}>
|
|
336
|
+
<Power size={16} /> 关闭服务
|
|
243
337
|
</button>
|
|
244
338
|
</div>
|
|
245
339
|
</ModalOverlay>
|
|
246
340
|
)
|
|
247
341
|
}
|
|
248
342
|
|
|
249
|
-
// === Toast ===
|
|
250
|
-
const TOAST_COLORS = { success: '#22c55e', error: '#ef4444', warning: '#f59e0b', info: '#3b82f6' }
|
|
251
|
-
|
|
252
343
|
function Toast({ message, type, onDone, index }) {
|
|
253
344
|
useEffect(() => {
|
|
254
345
|
const t = setTimeout(onDone, 3000)
|
|
255
346
|
return () => clearTimeout(t)
|
|
256
347
|
}, [])
|
|
257
348
|
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
|
-
}}>
|
|
349
|
+
<div className={`toast ${type}`} style={{ bottom: 24 + index * 60 }}>
|
|
265
350
|
{message}
|
|
266
351
|
</div>
|
|
267
352
|
)
|
|
268
353
|
}
|
|
269
354
|
|
|
270
|
-
// ===
|
|
271
|
-
function ModalOverlay({ children, onClose }) {
|
|
355
|
+
// === 遮罩层 ===
|
|
356
|
+
function ModalOverlay({ children, onClose, closeOnOverlayClick = false }) {
|
|
357
|
+
const handleOverlayClick = (e) => {
|
|
358
|
+
if (closeOnOverlayClick && e.target === e.currentTarget) {
|
|
359
|
+
onClose?.()
|
|
360
|
+
}
|
|
361
|
+
}
|
|
272
362
|
return (
|
|
273
|
-
<div
|
|
363
|
+
<div className="modal-overlay" onClick={handleOverlayClick}>
|
|
274
364
|
{children}
|
|
275
365
|
</div>
|
|
276
366
|
)
|
|
277
367
|
}
|
|
278
368
|
|
|
279
|
-
// ===
|
|
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 ===
|
|
369
|
+
// === 面包屑生成器 ===
|
|
291
370
|
function generateBreadcrumbs(currentPath) {
|
|
292
371
|
if (!currentPath) return []
|
|
293
372
|
return [
|
|
@@ -299,18 +378,14 @@ function generateBreadcrumbs(currentPath) {
|
|
|
299
378
|
]
|
|
300
379
|
}
|
|
301
380
|
|
|
302
|
-
// ===
|
|
381
|
+
// === 刷新处理器工厂 ===
|
|
303
382
|
const createRefreshHandler = (setter, apiMethod) => async () => {
|
|
304
383
|
try { setter(await apiMethod()) }
|
|
305
384
|
catch (err) { console.error(err) }
|
|
306
385
|
}
|
|
307
386
|
|
|
308
|
-
// ===
|
|
387
|
+
// === 文件卡片 ===
|
|
309
388
|
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
389
|
const subtype = getFileSubtype(file.fileName)
|
|
315
390
|
|
|
316
391
|
return (
|
|
@@ -318,179 +393,161 @@ function FileCard({ file, isSelected, isDarkMode, onSelect, onPreview }) {
|
|
|
318
393
|
data-id={file.cid}
|
|
319
394
|
onClick={() => onSelect(file.cid)}
|
|
320
395
|
onDoubleClick={() => onPreview(file)}
|
|
321
|
-
|
|
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
|
-
}}
|
|
396
|
+
className={`card ${isSelected ? 'selected' : ''}`}
|
|
328
397
|
>
|
|
329
|
-
<div
|
|
330
|
-
...iconContainerStyle,
|
|
331
|
-
background: file.starred ? 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%)' : 'linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%)'
|
|
332
|
-
}}>
|
|
398
|
+
<div className={`card-icon ${file.starred ? 'starred' : 'file'}`}>
|
|
333
399
|
{subtype === 'image' && <ImageIcon size={24} color="#fff" />}
|
|
334
400
|
{subtype === 'video' && <Film size={24} color="#fff" />}
|
|
335
401
|
{subtype === 'audio' && <Music size={24} color="#fff" />}
|
|
336
402
|
{subtype === 'file' && <FileText size={24} color="#fff" />}
|
|
337
403
|
</div>
|
|
338
|
-
<p
|
|
404
|
+
<p className="card-name">
|
|
339
405
|
{parseName(file.fileName).name}
|
|
340
406
|
</p>
|
|
341
407
|
</div>
|
|
342
408
|
)
|
|
343
409
|
}
|
|
344
410
|
|
|
345
|
-
// ===
|
|
411
|
+
// === 文件夹卡片 ===
|
|
346
412
|
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
413
|
return (
|
|
352
414
|
<div
|
|
353
415
|
onClick={onClick}
|
|
354
|
-
|
|
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
|
-
}}
|
|
416
|
+
className="card"
|
|
361
417
|
>
|
|
362
|
-
<div
|
|
418
|
+
<div className="card-icon folder">
|
|
363
419
|
<Folder size={28} color="#fff" />
|
|
364
420
|
</div>
|
|
365
|
-
<p
|
|
421
|
+
<p className="card-name">
|
|
366
422
|
{folder.name}
|
|
367
423
|
</p>
|
|
368
424
|
</div>
|
|
369
425
|
)
|
|
370
426
|
}
|
|
371
427
|
|
|
372
|
-
// ===
|
|
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)'
|
|
428
|
+
// === 确认弹窗 ===
|
|
429
|
+
function ConfirmModal({ title, message, confirmText, onConfirm, onClose, danger, isDarkMode, closeOnOverlayClick }) {
|
|
380
430
|
return (
|
|
381
|
-
<ModalOverlay onClose={onClose}>
|
|
382
|
-
<div
|
|
383
|
-
<h3
|
|
384
|
-
<p
|
|
385
|
-
<div
|
|
386
|
-
<button onClick={onClose}
|
|
387
|
-
<button onClick={onConfirm}
|
|
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>
|
|
388
438
|
</div>
|
|
389
439
|
</div>
|
|
390
440
|
</ModalOverlay>
|
|
391
441
|
)
|
|
392
442
|
}
|
|
393
443
|
|
|
394
|
-
// ===
|
|
395
|
-
function InputModal({ title, placeholder, defaultValue, confirmText, onConfirm, onClose }) {
|
|
444
|
+
// === 输入弹窗 ===
|
|
445
|
+
function InputModal({ title, placeholder, defaultValue, confirmText, onConfirm, onClose, isDarkMode, isLoading, loadingText }) {
|
|
396
446
|
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
447
|
return (
|
|
403
448
|
<ModalOverlay onClose={onClose}>
|
|
404
|
-
<div
|
|
405
|
-
<h3
|
|
449
|
+
<div className="input-modal" onClick={e => e.stopPropagation()}>
|
|
450
|
+
<h3>{title}</h3>
|
|
406
451
|
<input
|
|
407
452
|
type="text"
|
|
408
453
|
value={value}
|
|
409
454
|
onChange={(e) => setValue(e.target.value)}
|
|
410
455
|
placeholder={placeholder}
|
|
411
456
|
autoFocus
|
|
412
|
-
onKeyDown={(e) => { if (e.key === 'Enter' && value.trim()) onConfirm(value.trim()) }}
|
|
413
|
-
|
|
457
|
+
onKeyDown={(e) => { if (e.key === 'Enter' && value.trim() && !isLoading) onConfirm(value.trim()) }}
|
|
458
|
+
className="modal-input"
|
|
414
459
|
/>
|
|
415
|
-
<div
|
|
416
|
-
<button onClick={onClose}
|
|
417
|
-
<button onClick={() => value.trim() && onConfirm(value.trim())} disabled={!value.trim()} style={{
|
|
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>
|
|
418
463
|
</div>
|
|
419
464
|
</div>
|
|
420
465
|
</ModalOverlay>
|
|
421
466
|
)
|
|
422
467
|
}
|
|
423
468
|
|
|
424
|
-
// ===
|
|
469
|
+
// === 移动弹窗 ===
|
|
425
470
|
function MoveModal({ items, allFolders, currentPath, isDarkMode, onMove, onClose }) {
|
|
426
471
|
const [targetPath, setTargetPath] = useState('')
|
|
427
|
-
const
|
|
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'
|
|
472
|
+
const [customPath, setCustomPath] = useState(currentPath)
|
|
433
473
|
|
|
434
474
|
const breadcrumbParts = generateBreadcrumbs(targetPath)
|
|
435
475
|
|
|
476
|
+
const handleConfirm = () => {
|
|
477
|
+
const finalPath = targetPath || customPath.trim()
|
|
478
|
+
onMove(finalPath)
|
|
479
|
+
}
|
|
480
|
+
|
|
436
481
|
return (
|
|
437
482
|
<ModalOverlay onClose={onClose}>
|
|
438
|
-
<div
|
|
439
|
-
<div
|
|
440
|
-
<h3
|
|
441
|
-
<button onClick={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>
|
|
442
487
|
</div>
|
|
443
|
-
<p style={{ fontSize: 12, color:
|
|
444
|
-
<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">
|
|
445
499
|
{breadcrumbParts.map((part, i) => (
|
|
446
500
|
<React.Fragment key={part.path}>
|
|
447
|
-
{i > 0 && <span style={{ color:
|
|
501
|
+
{i > 0 && <span style={{ color: 'var(--text-secondary)' }}>/</span>}
|
|
448
502
|
<button
|
|
449
503
|
key={part.path}
|
|
450
|
-
onClick={() =>
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
background: targetPath === part.path ? accentBlue + '20' : bgTertiary,
|
|
454
|
-
color: targetPath === part.path ? accentBlue : textSecondary,
|
|
455
|
-
cursor: 'pointer', fontSize: 12
|
|
504
|
+
onClick={() => {
|
|
505
|
+
setTargetPath(part.path)
|
|
506
|
+
setCustomPath(part.path)
|
|
456
507
|
}}
|
|
508
|
+
className={`move-breadcrumb-btn ${targetPath === part.path && !customPath ? 'active' : ''}`}
|
|
457
509
|
>
|
|
458
510
|
{part.name}
|
|
459
511
|
</button>
|
|
460
512
|
</React.Fragment>
|
|
461
513
|
))}
|
|
462
514
|
</div>
|
|
463
|
-
<div
|
|
464
|
-
{allFolders.filter(f =>
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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 => (
|
|
468
536
|
<button
|
|
469
537
|
key={folder.path}
|
|
470
538
|
onClick={() => setTargetPath(folder.path)}
|
|
471
|
-
|
|
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
|
-
}}
|
|
539
|
+
className={`move-folder-item ${targetPath === folder.path && !customPath ? 'selected' : ''}`}
|
|
476
540
|
>
|
|
477
|
-
<Folder size={16}
|
|
541
|
+
<Folder size={16} />
|
|
478
542
|
<span>{folder.name}</span>
|
|
479
543
|
</button>
|
|
480
544
|
))}
|
|
481
545
|
</div>
|
|
482
|
-
<div
|
|
483
|
-
<button onClick={onClose}
|
|
546
|
+
<div className="modal-actions">
|
|
547
|
+
<button onClick={onClose} className="btn secondary">取消</button>
|
|
484
548
|
<button
|
|
485
|
-
onClick={
|
|
486
|
-
|
|
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
|
-
}}
|
|
549
|
+
onClick={handleConfirm}
|
|
550
|
+
className="btn primary"
|
|
494
551
|
>
|
|
495
552
|
移动
|
|
496
553
|
</button>
|
|
@@ -500,7 +557,7 @@ function MoveModal({ items, allFolders, currentPath, isDarkMode, onMove, onClose
|
|
|
500
557
|
)
|
|
501
558
|
}
|
|
502
559
|
|
|
503
|
-
// ===
|
|
560
|
+
// === 主应用 ===
|
|
504
561
|
export default function App() {
|
|
505
562
|
const [items, setItems] = useState([])
|
|
506
563
|
const [trashItems, setTrashItems] = useState([])
|
|
@@ -523,9 +580,54 @@ export default function App() {
|
|
|
523
580
|
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false)
|
|
524
581
|
const [confirmModal, setConfirmModal] = useState(null)
|
|
525
582
|
const [inputModal, setInputModal] = useState(null)
|
|
583
|
+
const [inputLoading, setInputLoading] = useState(false)
|
|
526
584
|
const [renameTarget, setRenameTarget] = useState(null)
|
|
527
585
|
const [showWelcome, setShowWelcome] = useState(() => !localStorage.getItem('mostbox_welcomed'))
|
|
528
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])
|
|
529
631
|
|
|
530
632
|
const currentPath = currentFolderId || ''
|
|
531
633
|
const allFolders = getUniqueFolders(items)
|
|
@@ -654,6 +756,7 @@ export default function App() {
|
|
|
654
756
|
if (isTrash) {
|
|
655
757
|
await API.permanentDeleteTrashFile(id)
|
|
656
758
|
} else {
|
|
759
|
+
// 跳过文件夹 ID(以 '__' 前缀标识),只删除文件
|
|
657
760
|
if (!id.startsWith('__')) await API.deletePublishedFile(id)
|
|
658
761
|
}
|
|
659
762
|
}
|
|
@@ -694,19 +797,30 @@ export default function App() {
|
|
|
694
797
|
defaultValue: currentName,
|
|
695
798
|
confirmText: '重命名',
|
|
696
799
|
onConfirm: async (newName) => {
|
|
697
|
-
setInputModal(null)
|
|
698
800
|
if (newName === currentName) return
|
|
801
|
+
setInputLoading(true)
|
|
699
802
|
try {
|
|
700
803
|
if (isFolder) {
|
|
701
|
-
|
|
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)
|
|
702
811
|
} else {
|
|
703
812
|
const { folder } = parseName(target.fileName)
|
|
704
813
|
const newFileName = folder ? `${folder}/${newName}` : newName
|
|
705
814
|
await API.moveFile(target.cid, newFileName)
|
|
815
|
+
addToast('已重命名', 'success')
|
|
816
|
+
refreshFiles()
|
|
706
817
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
818
|
+
setInputModal(null)
|
|
819
|
+
} catch {
|
|
820
|
+
addToast('重命名失败', 'error')
|
|
821
|
+
} finally {
|
|
822
|
+
setInputLoading(false)
|
|
823
|
+
}
|
|
710
824
|
}
|
|
711
825
|
})
|
|
712
826
|
}
|
|
@@ -718,7 +832,14 @@ export default function App() {
|
|
|
718
832
|
for (const file of Array.from(files)) {
|
|
719
833
|
const fileName = prefix + file.name
|
|
720
834
|
|
|
721
|
-
//
|
|
835
|
+
// 检查完整路径是否已存在
|
|
836
|
+
const nameExists = items.some(item => item.fileName === fileName)
|
|
837
|
+
if (nameExists) {
|
|
838
|
+
addToast(`${file.name} 已存在`, 'warning')
|
|
839
|
+
continue
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// 创建传输条目用于进度跟踪
|
|
722
843
|
const transferId = `upload_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
723
844
|
const transfer = {
|
|
724
845
|
id: transferId,
|
|
@@ -730,7 +851,7 @@ export default function App() {
|
|
|
730
851
|
newTransfers.push(transfer)
|
|
731
852
|
setTransfers(prev => [...prev, transfer])
|
|
732
853
|
|
|
733
|
-
//
|
|
854
|
+
// 有新传输时打开传输面板
|
|
734
855
|
if (newTransfers.length > 0) {
|
|
735
856
|
setIsTransferPanelOpen(true)
|
|
736
857
|
}
|
|
@@ -738,13 +859,13 @@ export default function App() {
|
|
|
738
859
|
try {
|
|
739
860
|
const result = await API.publishFile(file, fileName)
|
|
740
861
|
if (result.alreadyExists) {
|
|
741
|
-
//
|
|
862
|
+
// 更新传输状态
|
|
742
863
|
setTransfers(prev => prev.map(t =>
|
|
743
864
|
t.id === transferId ? { ...t, status: 'completed' } : t
|
|
744
865
|
))
|
|
745
866
|
addToast(`${file.name} 已存在`, 'warning')
|
|
746
867
|
} else {
|
|
747
|
-
//
|
|
868
|
+
// 更新传输状态
|
|
748
869
|
setTransfers(prev => prev.map(t =>
|
|
749
870
|
t.id === transferId ? { ...t, progress: 100, status: 'completed' } : t
|
|
750
871
|
))
|
|
@@ -758,39 +879,28 @@ export default function App() {
|
|
|
758
879
|
}
|
|
759
880
|
}
|
|
760
881
|
|
|
761
|
-
//
|
|
882
|
+
// 延迟移除已完成的传输
|
|
762
883
|
setTimeout(() => {
|
|
763
|
-
setTransfers(prev => prev.filter(t => t.status
|
|
884
|
+
setTransfers(prev => prev.filter(t => t.status !== 'completed' && t.status !== 'error' && t.status !== 'cancelled'))
|
|
764
885
|
}, 3000)
|
|
765
886
|
|
|
766
887
|
refreshFiles()
|
|
767
888
|
refreshStorageStats()
|
|
768
889
|
}
|
|
769
890
|
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
})
|
|
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)
|
|
794
904
|
}
|
|
795
905
|
|
|
796
906
|
const handleCopyLink = () => {
|
|
@@ -813,7 +923,7 @@ export default function App() {
|
|
|
813
923
|
const result = await API.downloadFile(downloadLink)
|
|
814
924
|
setDownloadLink('')
|
|
815
925
|
setIsDownloadModalOpen(false)
|
|
816
|
-
|
|
926
|
+
|
|
817
927
|
if (result.alreadyExists) {
|
|
818
928
|
addToast(`${result.fileName} 已存在`, 'warning')
|
|
819
929
|
} else {
|
|
@@ -822,7 +932,7 @@ export default function App() {
|
|
|
822
932
|
fileName: '下载文件',
|
|
823
933
|
progress: 0,
|
|
824
934
|
type: 'download',
|
|
825
|
-
status: '
|
|
935
|
+
status: 'downloading'
|
|
826
936
|
}
|
|
827
937
|
setTransfers(prev => [...prev, transfer])
|
|
828
938
|
setIsTransferPanelOpen(true)
|
|
@@ -834,18 +944,49 @@ export default function App() {
|
|
|
834
944
|
setIsDownloading(false)
|
|
835
945
|
}
|
|
836
946
|
}
|
|
837
|
-
|
|
947
|
+
|
|
838
948
|
const handleCancelTransfer = async (transfer) => {
|
|
839
|
-
if (transfer.type === 'download' && transfer.status === '
|
|
949
|
+
if (transfer.type === 'download' && transfer.status === 'downloading') {
|
|
840
950
|
try {
|
|
841
951
|
await API.cancelDownload(transfer.id)
|
|
842
|
-
//
|
|
952
|
+
// WebSocket 会处理 'download:cancelled' 事件
|
|
843
953
|
} catch (err) {
|
|
844
954
|
addToast('取消失败', 'error')
|
|
845
955
|
}
|
|
846
956
|
}
|
|
847
957
|
}
|
|
848
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
|
+
|
|
849
990
|
const handleNavigate = (path) => {
|
|
850
991
|
setCurrentFolderId(path || null)
|
|
851
992
|
setSelectedIds([])
|
|
@@ -872,7 +1013,7 @@ export default function App() {
|
|
|
872
1013
|
})
|
|
873
1014
|
}
|
|
874
1015
|
|
|
875
|
-
// WebSocket
|
|
1016
|
+
// WebSocket 连接
|
|
876
1017
|
useEffect(() => {
|
|
877
1018
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
878
1019
|
const ws = new WebSocket(`${protocol}//${location.host}/ws`)
|
|
@@ -892,17 +1033,17 @@ export default function App() {
|
|
|
892
1033
|
} else {
|
|
893
1034
|
addToast(`${data.fileName} 下载完成`, 'success')
|
|
894
1035
|
}
|
|
895
|
-
//
|
|
1036
|
+
// 延迟移除已完成的下载
|
|
896
1037
|
setTimeout(() => {
|
|
897
1038
|
setTransfers(prev => prev.filter(t => !(t.id === taskId && t.status === 'completed')))
|
|
898
1039
|
}, 3000)
|
|
899
1040
|
}
|
|
900
1041
|
}
|
|
901
|
-
//
|
|
1042
|
+
// 处理发布/上传进度
|
|
902
1043
|
if (event === 'publish:progress') {
|
|
903
1044
|
setTransfers(prev => prev.map(t => {
|
|
904
1045
|
if (data.file && t.fileName === data.file && t.type === 'upload') {
|
|
905
|
-
//
|
|
1046
|
+
// 根据阶段计算百分比
|
|
906
1047
|
let progress = 50
|
|
907
1048
|
if (data.stage === 'calculating-cid') progress = 25
|
|
908
1049
|
else if (data.stage === 'uploading') progress = 75
|
|
@@ -912,26 +1053,26 @@ export default function App() {
|
|
|
912
1053
|
return t
|
|
913
1054
|
}))
|
|
914
1055
|
}
|
|
915
|
-
//
|
|
1056
|
+
// 处理下载进度
|
|
916
1057
|
if (event === 'download:progress') {
|
|
917
1058
|
setTransfers(prev => prev.map(t =>
|
|
918
1059
|
t.id === data.taskId ? { ...t, progress: data.percent || 0, loaded: data.loaded, total: data.total } : t
|
|
919
1060
|
))
|
|
920
1061
|
}
|
|
921
|
-
//
|
|
1062
|
+
// 处理下载错误
|
|
922
1063
|
if (event === 'download:error') {
|
|
923
1064
|
setTransfers(prev => prev.map(t =>
|
|
924
1065
|
t.id === data.taskId ? { ...t, status: 'error' } : t
|
|
925
1066
|
))
|
|
926
1067
|
addToast(`下载失败: ${data.error}`, 'error')
|
|
927
1068
|
}
|
|
928
|
-
//
|
|
1069
|
+
// 处理下载状态(包含文件名)
|
|
929
1070
|
if (event === 'download:status') {
|
|
930
1071
|
setTransfers(prev => prev.map(t =>
|
|
931
1072
|
t.id === data.taskId ? { ...t, fileName: data.file || t.fileName } : t
|
|
932
1073
|
))
|
|
933
1074
|
}
|
|
934
|
-
//
|
|
1075
|
+
// 处理下载取消
|
|
935
1076
|
if (event === 'download:cancelled') {
|
|
936
1077
|
setTransfers(prev => prev.map(t =>
|
|
937
1078
|
t.id === data.taskId ? { ...t, status: 'cancelled' } : t
|
|
@@ -943,7 +1084,7 @@ export default function App() {
|
|
|
943
1084
|
return () => ws.close()
|
|
944
1085
|
}, [])
|
|
945
1086
|
|
|
946
|
-
//
|
|
1087
|
+
// 初始化
|
|
947
1088
|
useEffect(() => {
|
|
948
1089
|
const saved = localStorage.getItem('theme')
|
|
949
1090
|
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
@@ -955,182 +1096,171 @@ export default function App() {
|
|
|
955
1096
|
API.getStorageStats().then(s => setStorageStats(s)).catch(() => { })
|
|
956
1097
|
}, [])
|
|
957
1098
|
|
|
958
|
-
//
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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'
|
|
1099
|
+
// 同步 data-theme 属性
|
|
1100
|
+
useEffect(() => {
|
|
1101
|
+
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light')
|
|
1102
|
+
}, [isDarkMode])
|
|
967
1103
|
|
|
1104
|
+
// 主题颜色
|
|
968
1105
|
const viewTitle = currentView === 'all' ? '全部内容' : currentView === 'starred' ? '收藏' : '回收站'
|
|
969
|
-
const displayFiles = currentView === 'all'
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
+
// 面包屑部分
|
|
973
1116
|
const breadcrumbParts = generateBreadcrumbs(currentPath)
|
|
974
1117
|
|
|
975
1118
|
return (
|
|
976
|
-
<div
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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>
|
|
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>
|
|
989
1127
|
</div>
|
|
990
|
-
<nav
|
|
1128
|
+
<nav className="sidebar-nav">
|
|
991
1129
|
{[{ id: 'all', icon: <Files size={18} />, label: '全部内容' }, { id: 'starred', icon: <Star size={18} />, label: '收藏' }, { id: 'trash', icon: <Trash2 size={18} />, label: '回收站' }].map(item => (
|
|
992
1130
|
<button
|
|
993
1131
|
key={item.id}
|
|
994
|
-
onClick={() => { setCurrentView(item.id); setCurrentFolderId(null); setSelectedIds([]); setSearchQuery('') }}
|
|
995
|
-
|
|
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
|
-
}}
|
|
1132
|
+
onClick={() => { setCurrentView(item.id); setCurrentFolderId(null); setSelectedIds([]); setSearchQuery(''); setIsSidebarOpen(false) }}
|
|
1133
|
+
className={`sidebar-nav-btn ${currentView === item.id ? 'active' : ''}`}
|
|
1002
1134
|
>
|
|
1003
1135
|
{item.icon}
|
|
1004
1136
|
<span>{item.label}</span>
|
|
1005
1137
|
</button>
|
|
1006
1138
|
))}
|
|
1007
1139
|
</nav>
|
|
1008
|
-
<div
|
|
1009
|
-
<div
|
|
1010
|
-
<HardDrive size={14}
|
|
1011
|
-
<span
|
|
1140
|
+
<div className="sidebar-footer">
|
|
1141
|
+
<div className="sidebar-footer-label">
|
|
1142
|
+
<HardDrive size={14} />
|
|
1143
|
+
<span>存储空间</span>
|
|
1012
1144
|
</div>
|
|
1013
|
-
<div
|
|
1014
|
-
<div style={{ width: `${storageStats.total > 0 ? (storageStats.used / storageStats.total) * 100 : 0}
|
|
1145
|
+
<div className="storage-bar">
|
|
1146
|
+
<div className="storage-bar-fill" style={{ width: `${storageStats.total > 0 ? (storageStats.used / storageStats.total) * 100 : 0}%` }} />
|
|
1015
1147
|
</div>
|
|
1016
|
-
<div
|
|
1148
|
+
<div className="storage-info">
|
|
1017
1149
|
<span>{formatSize(storageStats.used)}</span>
|
|
1018
1150
|
<span>{storageStats.total > 0 ? formatSize(storageStats.total) : '-'}</span>
|
|
1019
1151
|
</div>
|
|
1020
1152
|
</div>
|
|
1021
1153
|
</div>
|
|
1022
1154
|
|
|
1023
|
-
{/*
|
|
1024
|
-
<div
|
|
1025
|
-
{/*
|
|
1026
|
-
<header
|
|
1027
|
-
<div
|
|
1028
|
-
<
|
|
1029
|
-
|
|
1030
|
-
|
|
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' : ''}`} />
|
|
1031
1166
|
{peerCount > 0 ? `${peerCount} 节点` : '等待连接'}
|
|
1032
1167
|
</div>
|
|
1033
1168
|
</div>
|
|
1034
|
-
<div
|
|
1035
|
-
|
|
1036
|
-
<
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
</div>
|
|
1041
|
-
)}
|
|
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>
|
|
1042
1175
|
{currentView === 'trash' && trashItems.length > 0 && (
|
|
1043
|
-
<button onClick={handleEmptyTrash} style={{
|
|
1176
|
+
<button onClick={handleEmptyTrash} className="btn small" style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}>
|
|
1044
1177
|
清空回收站
|
|
1045
1178
|
</button>
|
|
1046
1179
|
)}
|
|
1047
|
-
<button onClick={() => setIsTransferPanelOpen(true)}
|
|
1180
|
+
<button onClick={() => setIsTransferPanelOpen(true)} className="icon-btn">
|
|
1048
1181
|
<ArrowUpDown size={16} />
|
|
1049
|
-
{transfers.length > 0 && <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} />
|
|
1182
|
+
{transfers.length > 0 && <span className="icon-btn-badge">{transfers.length}</span>}
|
|
1053
1183
|
</button>
|
|
1054
|
-
<button onClick={() => setIsDarkMode(!isDarkMode)}
|
|
1184
|
+
<button onClick={() => setIsDarkMode(!isDarkMode)} className="icon-btn theme-toggle">
|
|
1055
1185
|
{isDarkMode ? <Sun size={16} /> : <Moon size={16} />}
|
|
1056
1186
|
</button>
|
|
1057
|
-
<button onClick={
|
|
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 }}>
|
|
1187
|
+
<button onClick={() => setShowSettings(true)} className="icon-btn">
|
|
1061
1188
|
<Info size={16} />
|
|
1062
1189
|
</button>
|
|
1063
1190
|
</div>
|
|
1064
1191
|
</header>
|
|
1065
1192
|
|
|
1066
|
-
{/*
|
|
1193
|
+
{/* 上传/下载 */}
|
|
1067
1194
|
{currentView === 'all' && (
|
|
1068
|
-
<div
|
|
1069
|
-
<div onDragOver={(e) => { e.preventDefault(); setIsDraggingOverUpload(true) }} onDragLeave={() => setIsDraggingOverUpload(false)} onDrop={(e) => { e.preventDefault(); setIsDraggingOverUpload(false); processFiles(e.dataTransfer.files) }}
|
|
1070
|
-
<input type="file" multiple onChange={(e) => processFiles(e.target.files)}
|
|
1071
|
-
<Upload size={20}
|
|
1072
|
-
<p
|
|
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>
|
|
1073
1200
|
</div>
|
|
1074
|
-
<div onClick={() => setIsDownloadModalOpen(true)}
|
|
1075
|
-
<Download size={20}
|
|
1076
|
-
<p
|
|
1201
|
+
<div className="action-card action-card-download" onClick={() => setIsDownloadModalOpen(true)}>
|
|
1202
|
+
<Download size={20} style={{ marginBottom: 8 }} />
|
|
1203
|
+
<p>下载文件</p>
|
|
1077
1204
|
</div>
|
|
1078
1205
|
</div>
|
|
1079
1206
|
)}
|
|
1080
1207
|
|
|
1081
|
-
{/*
|
|
1082
|
-
{currentView === 'all' &&
|
|
1083
|
-
<div
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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}
|
|
1091
1227
|
</div>
|
|
1092
1228
|
)}
|
|
1093
1229
|
|
|
1094
|
-
{/*
|
|
1095
|
-
<div
|
|
1096
|
-
{/*
|
|
1230
|
+
{/* 内容网格 */}
|
|
1231
|
+
<div className="content-grid">
|
|
1232
|
+
{/* 回收站视图 */}
|
|
1097
1233
|
{currentView === 'trash' && (
|
|
1098
|
-
|
|
1099
|
-
<div
|
|
1234
|
+
displayFiles.length === 0 ? (
|
|
1235
|
+
<div className="empty-state">{searchQuery ? '未找到相关文件' : '回收站是空的'}</div>
|
|
1100
1236
|
) : (
|
|
1101
|
-
<div
|
|
1102
|
-
{
|
|
1237
|
+
<div className="file-grid">
|
|
1238
|
+
{displayFiles.map(f => (
|
|
1103
1239
|
<div
|
|
1104
1240
|
key={f.cid}
|
|
1105
1241
|
onClick={() => setSelectedIds(prev => prev.includes(f.cid) ? prev.filter(id => id !== f.cid) : [...prev, f.cid])}
|
|
1106
1242
|
onDoubleClick={() => handleRestore(f.cid)}
|
|
1107
|
-
|
|
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
|
-
}}
|
|
1243
|
+
className={`card ${selectedIds.includes(f.cid) ? 'selected' : ''}`}
|
|
1114
1244
|
>
|
|
1115
|
-
<div
|
|
1245
|
+
<div className="card-icon trash">
|
|
1116
1246
|
<FileText size={24} color="#fff" />
|
|
1117
1247
|
</div>
|
|
1118
|
-
<p
|
|
1119
|
-
<p
|
|
1248
|
+
<p className="card-name">{parseName(f.fileName).name}</p>
|
|
1249
|
+
<p className="card-date">删除于 {formatDate(f.deletedAt)}</p>
|
|
1120
1250
|
</div>
|
|
1121
1251
|
))}
|
|
1122
1252
|
</div>
|
|
1123
1253
|
)
|
|
1124
1254
|
)}
|
|
1125
1255
|
|
|
1126
|
-
{/*
|
|
1256
|
+
{/* 全部/收藏视图 */}
|
|
1127
1257
|
{currentView !== 'trash' && (
|
|
1128
1258
|
displayFiles.length === 0 && displayFolders.length === 0 ? (
|
|
1129
|
-
<div
|
|
1259
|
+
<div className="empty-state">
|
|
1130
1260
|
{searchQuery ? '未找到相关文件' : (currentView === 'starred' ? '暂无收藏' : '暂无文件')}
|
|
1131
1261
|
</div>
|
|
1132
1262
|
) : (
|
|
1133
|
-
<div
|
|
1263
|
+
<div className="file-grid">
|
|
1134
1264
|
{displayFolders.map(folder => (
|
|
1135
1265
|
<FolderCard key={folder.path} folder={folder} isDarkMode={isDarkMode} onClick={() => handleNavigate(folder.path)} />
|
|
1136
1266
|
))}
|
|
@@ -1145,30 +1275,35 @@ export default function App() {
|
|
|
1145
1275
|
|
|
1146
1276
|
|
|
1147
1277
|
|
|
1148
|
-
{/*
|
|
1278
|
+
{/* 确认弹窗 */}
|
|
1149
1279
|
{confirmModal && (
|
|
1150
1280
|
<ConfirmModal
|
|
1151
1281
|
title={confirmModal.title}
|
|
1152
1282
|
message={confirmModal.message}
|
|
1153
1283
|
confirmText={confirmModal.confirmText}
|
|
1154
1284
|
danger={confirmModal.danger}
|
|
1285
|
+
isDarkMode={isDarkMode}
|
|
1286
|
+
closeOnOverlayClick={confirmModal.danger}
|
|
1155
1287
|
onConfirm={confirmModal.onConfirm}
|
|
1156
1288
|
onClose={() => setConfirmModal(null)}
|
|
1157
1289
|
/>
|
|
1158
1290
|
)}
|
|
1159
1291
|
|
|
1160
|
-
{/*
|
|
1292
|
+
{/* 输入弹窗 */}
|
|
1161
1293
|
{inputModal && (
|
|
1162
1294
|
<InputModal
|
|
1163
1295
|
title={inputModal.title}
|
|
1164
1296
|
placeholder={inputModal.placeholder}
|
|
1297
|
+
defaultValue={inputModal.defaultValue}
|
|
1165
1298
|
confirmText={inputModal.confirmText}
|
|
1299
|
+
isDarkMode={isDarkMode}
|
|
1300
|
+
isLoading={inputLoading}
|
|
1166
1301
|
onConfirm={inputModal.onConfirm}
|
|
1167
1302
|
onClose={() => setInputModal(null)}
|
|
1168
1303
|
/>
|
|
1169
1304
|
)}
|
|
1170
1305
|
|
|
1171
|
-
{/*
|
|
1306
|
+
{/* 移动弹窗 */}
|
|
1172
1307
|
{isMoveModalOpen && (
|
|
1173
1308
|
<MoveModal
|
|
1174
1309
|
items={selectedIds.map(id => items.find(i => i.cid === id)).filter(Boolean)}
|
|
@@ -1180,17 +1315,17 @@ export default function App() {
|
|
|
1180
1315
|
/>
|
|
1181
1316
|
)}
|
|
1182
1317
|
|
|
1183
|
-
{/*
|
|
1318
|
+
{/* 分享弹窗 */}
|
|
1184
1319
|
{shareItem && (
|
|
1185
1320
|
<ModalOverlay onClose={() => setShareItem(null)}>
|
|
1186
|
-
<div
|
|
1187
|
-
<div
|
|
1188
|
-
<h3
|
|
1189
|
-
<button onClick={() => 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>
|
|
1190
1325
|
</div>
|
|
1191
|
-
<div
|
|
1192
|
-
<div
|
|
1193
|
-
<button onClick={handleCopyLink}
|
|
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' : ''}`}>
|
|
1194
1329
|
{copied ? <Check size={18} /> : <Copy size={18} />}
|
|
1195
1330
|
</button>
|
|
1196
1331
|
</div>
|
|
@@ -1198,51 +1333,113 @@ export default function App() {
|
|
|
1198
1333
|
</ModalOverlay>
|
|
1199
1334
|
)}
|
|
1200
1335
|
|
|
1201
|
-
{/*
|
|
1336
|
+
{/* 下载弹窗 */}
|
|
1202
1337
|
{isDownloadModalOpen && (
|
|
1203
1338
|
<ModalOverlay onClose={() => setIsDownloadModalOpen(false)}>
|
|
1204
|
-
<div
|
|
1205
|
-
<div
|
|
1206
|
-
<h3
|
|
1207
|
-
<button onClick={() => 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>
|
|
1208
1343
|
</div>
|
|
1209
|
-
<input type="text" value={downloadLink} onChange={(e) => setDownloadLink(e.target.value)} placeholder="most://..." onKeyDown={(e) => e.key === 'Enter' && handleDownloadSharedFile()}
|
|
1210
|
-
<button onClick={handleDownloadSharedFile} disabled={!downloadLink.trim() || isDownloading}
|
|
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">
|
|
1211
1346
|
{isDownloading ? '下载中...' : '开始下载'}
|
|
1212
1347
|
</button>
|
|
1213
1348
|
</div>
|
|
1214
1349
|
</ModalOverlay>
|
|
1215
1350
|
)}
|
|
1216
1351
|
|
|
1217
|
-
{/*
|
|
1352
|
+
{/* 预览弹窗 */}
|
|
1218
1353
|
{previewItem && (
|
|
1219
|
-
<div
|
|
1220
|
-
<button
|
|
1354
|
+
<div className="preview-overlay" onClick={() => { setPreviewItem(null); setPreviewText(''); setPreviewMediaLoading(false) }}>
|
|
1355
|
+
<button className="preview-close"><X size={20} /></button>
|
|
1221
1356
|
<div onClick={e => e.stopPropagation()}>
|
|
1222
|
-
{previewItem.subtype === 'image' &&
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
+
)}
|
|
1225
1399
|
</div>
|
|
1226
1400
|
</div>
|
|
1227
1401
|
)}
|
|
1228
1402
|
|
|
1229
|
-
{/*
|
|
1403
|
+
{/* 批量操作栏 */}
|
|
1230
1404
|
{selectedIds.length > 0 && (
|
|
1231
|
-
<div
|
|
1232
|
-
<span
|
|
1233
|
-
<button onClick={() => setSelectedIds([])}
|
|
1234
|
-
<div
|
|
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" />
|
|
1235
1409
|
{currentView === 'trash' ? (
|
|
1236
1410
|
<>
|
|
1237
|
-
<button onClick={() =>
|
|
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">
|
|
1238
1419
|
恢复
|
|
1239
1420
|
</button>
|
|
1240
|
-
<button onClick={handleBatchDelete}
|
|
1421
|
+
<button onClick={handleBatchDelete} className="btn small danger">
|
|
1241
1422
|
永久删除
|
|
1242
1423
|
</button>
|
|
1243
1424
|
</>
|
|
1244
1425
|
) : (
|
|
1245
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
|
+
)}
|
|
1246
1443
|
<button onClick={() => {
|
|
1247
1444
|
const hasUnstarred = selectedIds.some(id => {
|
|
1248
1445
|
const item = items.find(i => i.cid === id)
|
|
@@ -1254,65 +1451,72 @@ export default function App() {
|
|
|
1254
1451
|
handleToggleStar(id)
|
|
1255
1452
|
}
|
|
1256
1453
|
})
|
|
1257
|
-
}} style={{
|
|
1454
|
+
}} className="btn small" style={{ background: '#f59e0b', color: '#fff' }}>
|
|
1258
1455
|
收藏
|
|
1259
1456
|
</button>
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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' }}>
|
|
1267
1466
|
移动
|
|
1268
1467
|
</button>
|
|
1269
|
-
<button onClick={handleBatchDelete}
|
|
1270
|
-
|
|
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
|
+
)}
|
|
1271
1478
|
</>
|
|
1272
1479
|
)}
|
|
1273
1480
|
</div>
|
|
1274
1481
|
)}
|
|
1275
1482
|
|
|
1276
|
-
{/*
|
|
1483
|
+
{/* 传输面板 */}
|
|
1277
1484
|
{isTransferPanelOpen && (
|
|
1278
|
-
<ModalOverlay onClose={() => setIsTransferPanelOpen(false)}>
|
|
1279
|
-
<div
|
|
1280
|
-
<div
|
|
1281
|
-
<h3
|
|
1282
|
-
<button onClick={() => setIsTransferPanelOpen(false)}
|
|
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>
|
|
1283
1490
|
</div>
|
|
1284
1491
|
{transfers.length === 0 ? (
|
|
1285
|
-
<div style={{ textAlign: 'center', color:
|
|
1492
|
+
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 24, fontSize: 13 }}>
|
|
1286
1493
|
暂无传输
|
|
1287
1494
|
</div>
|
|
1288
1495
|
) : (
|
|
1289
1496
|
transfers.map(t => (
|
|
1290
|
-
<div key={t.id}
|
|
1291
|
-
<div
|
|
1292
|
-
{t.type === 'upload' ? <Upload size={14}
|
|
1293
|
-
<span
|
|
1294
|
-
{t.status === '
|
|
1295
|
-
<button onClick={() => handleCancelTransfer(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">
|
|
1296
1503
|
<X size={14} />
|
|
1297
1504
|
</button>
|
|
1298
1505
|
)}
|
|
1299
1506
|
</div>
|
|
1300
|
-
<div
|
|
1301
|
-
<div
|
|
1302
|
-
<div
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
background: t.status === 'error' ? '#ef4444' : t.status === 'cancelled' ? '#f59e0b' : t.type === 'upload' ? accentBlue : '#6366f1',
|
|
1307
|
-
transition: 'width 0.2s'
|
|
1308
|
-
}} />
|
|
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
|
+
/>
|
|
1309
1513
|
</div>
|
|
1310
|
-
<span
|
|
1311
|
-
{t.status === 'completed' ? '完成' :
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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}%`}
|
|
1316
1520
|
</span>
|
|
1317
1521
|
</div>
|
|
1318
1522
|
</div>
|
|
@@ -1322,14 +1526,18 @@ export default function App() {
|
|
|
1322
1526
|
</ModalOverlay>
|
|
1323
1527
|
)}
|
|
1324
1528
|
|
|
1325
|
-
{/*
|
|
1529
|
+
{/* 通知列表 */}
|
|
1326
1530
|
{toasts.map((t, i) => <Toast key={t.id} message={t.message} type={t.type} onDone={() => removeToast(t.id)} index={i} />)}
|
|
1327
1531
|
|
|
1328
|
-
{/*
|
|
1329
|
-
{showWelcome && <WelcomeGuide onClose={handleCloseWelcome}
|
|
1532
|
+
{/* 引导页 */}
|
|
1533
|
+
{showWelcome && <WelcomeGuide onClose={handleCloseWelcome} onShutdown={() => {
|
|
1534
|
+
fetch('/api/shutdown', { method: 'POST' })
|
|
1535
|
+
addToast('服务已关闭,请重新启动应用', 'info')
|
|
1536
|
+
handleCloseWelcome()
|
|
1537
|
+
}} />}
|
|
1330
1538
|
|
|
1331
|
-
{/*
|
|
1332
|
-
{showSettings && <SettingsModal onClose={() => setShowSettings(false)} addToast={addToast} />}
|
|
1539
|
+
{/* 设置弹窗 */}
|
|
1540
|
+
{showSettings && <SettingsModal onClose={() => setShowSettings(false)} addToast={addToast} isDarkMode={isDarkMode} handleShutdown={handleShutdown} />}
|
|
1333
1541
|
</div>
|
|
1334
1542
|
)
|
|
1335
1543
|
}
|