Files tab: multi-selection, drag-and-drop, keyboard shortcuts, custom confirm modal, SVG icons

This commit is contained in:
2026-06-01 01:16:51 +08:00
parent 645d8878cc
commit 2487d3bbaa
33 changed files with 2325 additions and 50 deletions
+101
View File
@@ -0,0 +1,101 @@
<script>
export let title = 'Подтверждение'
export let message = ''
export let confirmText = 'Удалить'
export let cancelText = 'Отмена'
export let danger = false
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
</script>
<div class="overlay" on:click|self={() => dispatch('cancel')} role="dialog" aria-modal="true" aria-label={title}>
<div class="modal">
<h3>{title}</h3>
<p class="message">{message}</p>
<div class="actions">
<button class="btn {danger ? 'btn-danger' : 'btn-primary'}" on:click={() => dispatch('confirm')}>{confirmText}</button>
<button class="btn" on:click={() => dispatch('cancel')}>{cancelText}</button>
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: #1a1a28;
border: 1px solid #2a2a3c;
border-radius: 12px;
padding: 24px;
width: 360px;
max-width: 90vw;
}
h3 {
font-size: 18px;
margin-bottom: 12px;
color: #e4e4ef;
}
.message {
font-size: 14px;
color: #aaa;
margin-bottom: 20px;
line-height: 1.4;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn {
padding: 8px 16px;
border: 1px solid #2a2a3c;
background: #1a1a28;
color: #ccc;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
}
.btn:hover {
background: #222233;
}
.btn-primary {
background: #6366f1;
border-color: #6366f1;
color: #fff;
}
.btn-primary:hover {
background: #4f46e5;
}
.btn-danger {
background: #dc2626;
border-color: #dc2626;
color: #fff;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script>
import { createEventDispatcher } from 'svelte'
export let isFolder = false
export let fileId = ''
export let nodeId = ''
const dispatch = createEventDispatcher()
function handleOpen() {
if (isFolder) {
dispatch('openFolder', nodeId)
} else {
dispatch('open', fileId)
}
}
function handleDelete() {
dispatch('delete', nodeId)
}
</script>
<div class="file-actions">
<button class="action-btn" on:click={handleOpen} title={isFolder ? 'Open folder' : 'Open file'} aria-label={isFolder ? 'Open folder' : 'Open file'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if isFolder}
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/>
{:else}
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
{/if}
</svg>
</button>
<button class="action-btn action-btn-danger" on:click={handleDelete} title="Delete" aria-label="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
<style>
.file-actions {
display: flex;
gap: 2px;
align-items: center;
opacity: 0;
transition: opacity 0.15s ease;
}
:global(.file-row:hover) .file-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: #666;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.action-btn:hover {
background: #2a2a3c;
color: #ccc;
}
.action-btn-danger:hover {
background: #3a2222;
color: #ff6b6b;
}
.action-btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script>
import { createEventDispatcher } from 'svelte'
export let crumbs = []
const dispatch = createEventDispatcher()
function navigateTo(index) {
dispatch('navigate', index)
}
</script>
<nav class="breadcrumbs">
{#each crumbs as crumb, i}
{#if i > 0}
<span class="sep">/</span>
{/if}
{#if i === crumbs.length - 1}
<span class="crumb crumb--current">{crumb.name}</span>
{:else}
<button class="crumb crumb--link" on:click={() => navigateTo(i)}>{crumb.name}</button>
{/if}
{/each}
</nav>
<style>
.breadcrumbs {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 0;
font-size: 13px;
color: #999;
}
.sep {
color: #444;
}
.crumb {
font-size: 13px;
}
.crumb--current {
color: #ccc;
}
.crumb--link {
background: none;
border: none;
padding: 2px 4px;
color: #888;
cursor: pointer;
border-radius: 3px;
font-family: inherit;
font-size: 13px;
transition: color 0.12s, background 0.12s;
}
.crumb--link:hover {
color: #ccc;
background: #1e1e30;
}
.crumb--link:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script>
export let kind = 'generic'
export let size = 20
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
{#if kind === 'folder'}
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
{:else if kind === 'image'}
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
{:else if kind === 'video'}
<rect x="2" y="4" width="20" height="16" rx="2"/>
<polyline points="10 9 16 12 10 15 10 9"/>
{:else if kind === 'audio'}
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
{:else if kind === 'pdf'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="16" y2="16"/>
<line x1="8" y1="14" x2="12" y2="14"/>
{:else if kind === 'document'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
{:else if kind === 'spreadsheet'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="16" y2="16"/>
<line x1="8" y1="14" x2="12" y2="14"/>
<line x1="12" y1="12" x2="12" y2="18"/>
{:else if kind === 'presentation'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="9" y1="12" x2="15" y2="12"/>
<line x1="9" y1="15" x2="13" y2="15"/>
<line x1="12" y1="15" x2="12" y2="18"/>
{:else if kind === 'archive'}
<path d="M21 8v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/>
<polyline points="7 3 12 8 17 3"/>
<line x1="3" y1="8" x2="21" y2="8"/>
<rect x="10" y="12" width="4" height="4" rx="1"/>
{:else if kind === 'code'}
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
{:else if kind === 'text'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
{:else}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
{/if}
</svg>
+262
View File
@@ -0,0 +1,262 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
import FileIcon from './FileIcon.svelte'
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
export let item
export let content = '' // base64 data URI or text content
export let loading = false
export let error = ''
const dispatch = createEventDispatcher()
const kind = getFileKind(item)
$: showImage = isImageFile(item) && content && content.startsWith('data:')
$: showText = isTextFile(item) || isMarkdownFile(item)
$: showPdf = isPdfFile(item)
function handleKeydown(e) {
if (e.key === 'Escape') {
dispatch('close')
}
}
function handleOpenExternal() {
dispatch('openExternal', item.fileId)
}
onMount(() => {
window.addEventListener('keydown', handleKeydown)
})
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<div class="overlay" on:click|self={() => dispatch('close')} role="dialog" aria-modal="true" aria-label={`Preview: ${item.name}`}>
<div class="modal">
<header class="preview-header">
<div class="preview-title">
<FileIcon {kind} size={18}/>
<span class="preview-name" title={item.name}>{item.name}</span>
</div>
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
<div class="preview-actions">
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</button>
<button class="action-btn action-btn-close" on:click={() => dispatch('close')} title="Close" aria-label="Close preview">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</header>
<div class="preview-body">
{#if loading}
<div class="preview-status"><p>Loading preview...</p></div>
{:else if error}
<div class="preview-status">
<p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
</div>
{:else if showImage && content}
<div class="preview-image-container">
<img src={content} alt={item.name} class="preview-image"/>
</div>
{:else if showText && content}
<pre class="preview-text"><code>{content}</code></pre>
{:else if showPdf}
{#if content && content.startsWith('data:')}
<div class="preview-pdf-container">
<embed src={content} type="application/pdf" class="preview-pdf"/>
</div>
{:else}
<div class="preview-status">
<p>PDF preview is not available in this environment.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
</div>
{/if}
{:else}
<div class="preview-status">
<p>Preview is not available for this file type.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #14141f;
border: 1px solid #2a2a3c;
border-radius: 10px;
width: 90vw;
max-width: 900px;
height: 85vh;
max-height: 700px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #2a2a3c;
flex-shrink: 0;
}
.preview-title {
display: flex;
align-items: center;
gap: 8px;
color: #ddd;
font-size: 14px;
min-width: 0;
}
.preview-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-meta {
font-size: 11px;
color: #666;
margin-left: auto;
white-space: nowrap;
}
.preview-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: 8px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 4px;
background: transparent;
color: #666;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.action-btn:hover {
background: #2a2a3c;
color: #ccc;
}
.action-btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
.action-btn-close {
color: #ff6b6b;
}
.action-btn-close:hover {
background: #3a2222;
color: #ff4444;
}
.preview-body {
flex: 1;
overflow: auto;
min-height: 0;
}
.preview-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #888;
font-size: 14px;
}
.preview-image-container {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
min-height: 200px;
background: #0e0e18;
}
.preview-image {
max-width: 100%;
max-height: calc(85vh - 100px);
object-fit: contain;
border-radius: 4px;
}
.preview-text {
margin: 0;
padding: 16px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
color: #ccc;
white-space: pre-wrap;
word-wrap: break-word;
overflow: auto;
}
.preview-pdf-container {
width: 100%;
height: 100%;
}
.preview-pdf {
width: 100%;
height: 100%;
border: none;
}
.btn-sm {
padding: 6px 14px;
border: 1px solid #2a2a3c;
background: #1a1a28;
color: #ccc;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-family: inherit;
transition: background 0.12s;
}
.btn-sm:hover {
background: #222233;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
export function formatFileSize(bytes) {
if (bytes == null || bytes < 0) return '—'
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const val = bytes / Math.pow(1024, i)
return (i === 0 ? val.toFixed(0) : val.toFixed(1)) + ' ' + units[i]
}
const mimeLabels = {
'image/jpeg': 'JPEG image',
'image/png': 'PNG image',
'image/gif': 'GIF image',
'image/webp': 'WebP image',
'image/svg+xml': 'SVG image',
'image/bmp': 'BMP image',
'image/tiff': 'TIFF image',
'image/avif': 'AVIF image',
'application/pdf': 'PDF document',
'application/msword': 'Word document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
'application/vnd.ms-excel': 'Excel spreadsheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
'application/zip': 'ZIP archive',
'application/gzip': 'GZIP archive',
'application/x-tar': 'TAR archive',
'application/x-7z-compressed': '7z archive',
'application/x-rar-compressed': 'RAR archive',
'text/plain': 'Text file',
'text/html': 'HTML file',
'text/css': 'CSS file',
'text/javascript': 'JavaScript file',
'application/json': 'JSON file',
'application/xml': 'XML file',
'application/x-yaml': 'YAML file',
'application/octet-stream': 'Binary file',
'application/x-msdos-program': 'Executable',
'inode/directory': 'Folder',
}
export function formatMimeType(mime) {
if (!mime) return 'Unknown'
return mimeLabels[mime] || mime
}
export function getFileKind(item) {
if (item.type === 'folder') return 'folder'
const mime = (item.mime || '').toLowerCase()
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
if (mime.startsWith('text/')) return 'text'
if (mime.includes('pdf')) return 'pdf'
if (mime.includes('word') || mime.includes('document')) return 'document'
if (mime.includes('spreadsheet') || mime.includes('excel')) return 'spreadsheet'
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'presentation'
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gzip') || mime.includes('rar') || mime.includes('7z') || mime.includes('compress')) return 'archive'
if (mime.includes('json') || mime.includes('xml') || mime.includes('yaml') || mime.includes('javascript') || mime.includes('css') || mime.includes('html')) return 'code'
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
const codeExts = ['js','ts','jsx','tsx','vue','svelte','py','rs','go','c','cpp','h','hpp','java','kt','swift','rb','php','pl','sh','bash','zsh','fish','yml','yaml','json','xml','toml','ini','cfg','conf','md','markdown','css','scss','less','sass','sql','graphql','proto','gradle','cmake','makefile','dockerfile','env','gitignore']
if (codeExts.includes(ext)) return 'code'
return 'generic'
}
const imageMimes = ['image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/avif','image/svg+xml']
const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript']
const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','sh','py','js','ts','css','html','md','markdown','cfg']
const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']
export function canPreviewFile(item) {
if (item.type === 'folder') return false
const mime = (item.mime || '').toLowerCase()
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
if (imageMimes.includes(mime) || imageExts.includes(ext)) return true
if (mime.includes('pdf')) return true
if (textMimes.includes(mime) || codeNames.includes(ext)) return true
return false
}
export function isImageFile(item) {
const mime = (item.mime || '').toLowerCase()
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
return imageMimes.includes(mime) || imageExts.includes(ext)
}
export function isTextFile(item) {
const mime = (item.mime || '').toLowerCase()
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
return textMimes.includes(mime) || (codeNames.includes(ext) && ext !== 'md' && ext !== 'markdown')
}
export function isPdfFile(item) {
return (item.mime || '').toLowerCase().includes('pdf')
}
export function isMarkdownFile(item) {
const name = (item.name || '').toLowerCase()
return name.endsWith('.md') || name.endsWith('.markdown')
}
export function needsBase64Preview(item) {
return isImageFile(item) || isPdfFile(item)
}
export function needsTextPreview(item) {
return isTextFile(item) || isMarkdownFile(item)
}