- Selection model: click=select, dblclick=open, Ctrl+click toggle,
Shift+click range select, Ctrl+A, Esc to clear
- Keyboard shortcuts: Enter, Ctrl+Enter, F2, Backspace (navigate up),
Delete/Backspace (delete with confirm)
- Rename modal replaces prompt() with validation via backend ValidateName
- Context menu: Open, Open External, Show in Folder, Rename, Duplicate,
Copy, Cut, Delete — all with SVG icons and Russian labels
- Backend security: vaultPath/absPathSafe helpers prevent path traversal,
validateName rejects .. / \ null bytes empty overlong names
- MoveNode auto-renames on name conflict (copy style)
- Duplicate uses (copy) (copy 2) suffix pattern
- Russian localization: all file type labels, preview messages, tooltips
- FilePreviewModal: fixed broken {/if} tag
263 lines
6.7 KiB
Svelte
263 lines
6.7 KiB
Svelte
<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="Открыть во внешней программе" aria-label="Открыть внешне">
|
|
<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>Загрузка...</p></div>
|
|
{:else if error}
|
|
<div class="preview-status">
|
|
<p>{error}</p>
|
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</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 недоступен.</p>
|
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<div class="preview-status">
|
|
<p>Предпросмотр недоступен для этого типа файлов.</p>
|
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</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>
|