File manager: selection, keyboard shortcuts, rename modal, security, localization
- 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
This commit is contained in:
+143
-20
@@ -72,6 +72,11 @@
|
||||
let confirmAction = null
|
||||
let cancelAction = null
|
||||
|
||||
let showRename = false
|
||||
let renameId = ''
|
||||
let renameValue = ''
|
||||
let renameError = ''
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'notes', label: 'Заметки' },
|
||||
@@ -282,19 +287,8 @@
|
||||
}
|
||||
|
||||
function renameItem(id) {
|
||||
const name = prompt('Новое имя:')
|
||||
if (!name || !name.trim()) return
|
||||
renameSubmit(id, name.trim())
|
||||
}
|
||||
|
||||
async function renameSubmit(id, newName) {
|
||||
try {
|
||||
await wailsCall('RenameNode', id, newName)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await loadFolder(parentId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
const item = fileItems.find(x => x.id === id)
|
||||
if (item) openRename(item.id, item.name)
|
||||
}
|
||||
|
||||
function cutItem(id) {
|
||||
@@ -343,6 +337,20 @@
|
||||
selectedIds = fileItems.map(x => x.id)
|
||||
}
|
||||
|
||||
function rangeSelect(id) {
|
||||
if (fileItems.length === 0) return
|
||||
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
|
||||
const lastIdx = fileItems.findIndex(x => x.id === lastId)
|
||||
const curIdx = fileItems.findIndex(x => x.id === id)
|
||||
if (lastIdx === -1 || curIdx === -1) return
|
||||
const start = Math.min(lastIdx, curIdx)
|
||||
const end = Math.max(lastIdx, curIdx)
|
||||
const range = fileItems.slice(start, end + 1).map(x => x.id)
|
||||
const set = new Set(selectedIds)
|
||||
range.forEach(x => set.add(x))
|
||||
selectedIds = [...set]
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds = []
|
||||
}
|
||||
@@ -421,15 +429,106 @@
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'c') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a') { e.preventDefault(); selectAll() }
|
||||
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
|
||||
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
openSelected()
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() }
|
||||
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
||||
// Backspace without selection → navigate up
|
||||
if (e.key === 'Backspace' && folderStack.length > 0) {
|
||||
e.preventDefault()
|
||||
navigateBack()
|
||||
return
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
if (previewItem) { closePreview(); return }
|
||||
if (selectedIds.length > 0) { clearSelection(); return }
|
||||
} else if (e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
openRenameForSelection()
|
||||
}
|
||||
}
|
||||
|
||||
function openSelected() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
if (item.type === 'folder') {
|
||||
navigateToFolder(item.id)
|
||||
} else {
|
||||
openPreview(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectedExternal() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item && item.fileId) {
|
||||
wailsCall('OpenFile', item.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Rename modal =====
|
||||
|
||||
function openRename(id, currentName) {
|
||||
renameId = id
|
||||
renameValue = currentName
|
||||
renameError = ''
|
||||
showRename = true
|
||||
}
|
||||
|
||||
function openRenameForSelection() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
openRename(item.id, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRename() {
|
||||
const name = renameValue.trim()
|
||||
if (!name) { renameError = 'Имя не может быть пустым'; return }
|
||||
// Validate name via backend
|
||||
try {
|
||||
await wailsCall('ValidateName', name)
|
||||
} catch (e) {
|
||||
renameError = 'Недопустимое имя'
|
||||
return
|
||||
}
|
||||
showRename = false
|
||||
renameId = ''
|
||||
try {
|
||||
await wailsCall('RenameNode', renameId, name)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await loadFolder(parentId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
showRename = false
|
||||
renameId = ''
|
||||
renameValue = ''
|
||||
renameError = ''
|
||||
}
|
||||
|
||||
function onRenameKeydown(e) {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
else renameError = ''
|
||||
}
|
||||
|
||||
// ===== Confirm modal =====
|
||||
|
||||
function openConfirm(opts) {
|
||||
@@ -832,7 +931,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#if folderStack.length > 0}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
|
||||
const i = e.detail
|
||||
if (i === 0) {
|
||||
folderStack = []
|
||||
@@ -847,7 +946,7 @@
|
||||
Back
|
||||
</button>
|
||||
{:else}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
|
||||
{/if}
|
||||
|
||||
{#if fileItems.length === 0}
|
||||
@@ -877,6 +976,7 @@
|
||||
on:navigate={(e) => navigateToFolder(e.detail)}
|
||||
on:preview={(e) => openPreview(e.detail)}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
||||
on:delete={(e) => deleteFile(e.detail)}
|
||||
on:rename={(e) => renameItem(e.detail.id)}
|
||||
on:duplicate={(e) => duplicateItem(e.detail)}
|
||||
@@ -884,6 +984,7 @@
|
||||
on:copy={(e) => copyItem(e.detail)}
|
||||
on:selectOne={(e) => selectOne(e.detail)}
|
||||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
||||
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1007,6 +1108,26 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRename}
|
||||
<div class="modal-overlay" on:click|self={cancelRename}>
|
||||
<div class="modal">
|
||||
<h3>Переименовать</h3>
|
||||
<div class="form-group">
|
||||
<label>Новое имя</label>
|
||||
<input type="text" bind:value={renameValue}
|
||||
on:keydown={onRenameKeydown} />
|
||||
</div>
|
||||
{#if renameError}
|
||||
<div class="rename-error">{renameError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
|
||||
<button class="btn" on:click={cancelRename}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showConfirm}
|
||||
<ConfirmModal
|
||||
title={confirmTitle}
|
||||
@@ -1159,4 +1280,6 @@
|
||||
.import-summary { margin-bottom: 16px; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
||||
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
||||
|
||||
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import FileIcon from './lib/FileIcon.svelte'
|
||||
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
|
||||
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
|
||||
|
||||
export let item
|
||||
export let selected = false
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
const kind = getFileKind(item)
|
||||
const isFolder = item.type === 'folder'
|
||||
const fileType = formatFileType(item)
|
||||
let menuOpen = false
|
||||
let clickTimer = null
|
||||
|
||||
@@ -25,7 +26,7 @@
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
// Double click
|
||||
// Double click → открыть
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
@@ -34,37 +35,23 @@
|
||||
} else {
|
||||
clickTimer = setTimeout(() => {
|
||||
clickTimer = null
|
||||
// Single click: select
|
||||
if (selected) {
|
||||
// Already selected: navigate/preview
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
} else {
|
||||
dispatch('selectOne', item.id)
|
||||
}
|
||||
// Single click → выделить
|
||||
dispatch('selectOne', item.id)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenExternal() {
|
||||
dispatch('openExternal', item.fileId)
|
||||
}
|
||||
|
||||
function handleShowInFolder() {
|
||||
dispatch('showInFolder', item.id)
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
menuOpen = false
|
||||
dispatch('delete', { id: item.id, type: item.type })
|
||||
}
|
||||
|
||||
@@ -88,6 +75,15 @@
|
||||
dispatch('copy', item.id)
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
menuOpen = false
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen
|
||||
}
|
||||
@@ -107,48 +103,48 @@
|
||||
function handleDrop(e) {
|
||||
if (onDrop && isFolder) onDrop(e, item.id)
|
||||
}
|
||||
|
||||
function oncontextmenu(e) {
|
||||
e.preventDefault()
|
||||
menuOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeMenu}/>
|
||||
|
||||
<div class="file-row"
|
||||
class:file-row--selected={selected}
|
||||
class:file-row--dragover={false}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleKeydown}
|
||||
on:contextmenu={oncontextmenu}
|
||||
on:dragstart={handleDragStart}
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDrop}
|
||||
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
|
||||
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
|
||||
<div class="file-row-icon">
|
||||
<FileIcon {kind} size={22}/>
|
||||
</div>
|
||||
<div class="file-row-body">
|
||||
<div class="file-row-name" title={item.name}>{item.name}</div>
|
||||
<div class="file-row-meta">
|
||||
{#if isFolder}
|
||||
<span>Folder</span>
|
||||
{:else}
|
||||
<span>{fileType}</span>
|
||||
{#if !isFolder}
|
||||
<span class="meta-sep">·</span>
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
{#if item.mime}
|
||||
<span class="meta-sep">·</span>
|
||||
<span>{formatMimeType(item.mime)}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-row-actions">
|
||||
{#if !isFolder}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview">
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} 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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
||||
<button class="action-btn" on:click|stopPropagation={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"/>
|
||||
@@ -156,21 +152,21 @@
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} 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="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"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}>
|
||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<circle cx="12" cy="19" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete">
|
||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} 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">
|
||||
<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"/>
|
||||
@@ -183,10 +179,42 @@
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
||||
<div class="menu" on:click|stopPropagation role="menu">
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
|
||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
|
||||
<button class="menu-item" on:click={handleOpen} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
Открыть
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
|
||||
<svg width="14" height="14" 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>
|
||||
{#if isFolder}
|
||||
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>
|
||||
Показать в проводнике
|
||||
</button>
|
||||
{/if}
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Переименовать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Дублировать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
|
||||
Вырезать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Копировать
|
||||
</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
|
||||
<svg width="14" height="14" 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>
|
||||
{/if}
|
||||
|
||||
@@ -201,6 +229,7 @@
|
||||
transition: background 0.12s;
|
||||
min-height: 52px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
@@ -314,17 +343,19 @@
|
||||
margin-top: 4px;
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
min-width: 140px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
min-width: 220px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
@@ -337,10 +368,25 @@
|
||||
|
||||
.menu-item:hover {
|
||||
background: #2a2a3c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menu-item-danger {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.menu-item-danger:hover {
|
||||
background: #3a2222;
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.menu-sep {
|
||||
height: 1px;
|
||||
background: #2a2a3c;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</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">
|
||||
<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"/>
|
||||
@@ -60,11 +60,11 @@
|
||||
</header>
|
||||
<div class="preview-body">
|
||||
{#if loading}
|
||||
<div class="preview-status"><p>Loading preview...</p></div>
|
||||
<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}>Open in external program</button>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{:else if showImage && content}
|
||||
<div class="preview-image-container">
|
||||
@@ -79,14 +79,14 @@
|
||||
</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>
|
||||
<p>Предпросмотр PDF недоступен.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</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>
|
||||
<p>Предпросмотр недоступен для этого типа файлов.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -8,43 +8,53 @@ export function formatFileSize(bytes) {
|
||||
}
|
||||
|
||||
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',
|
||||
'image/jpeg': 'Изображение JPEG',
|
||||
'image/png': 'Изображение PNG',
|
||||
'image/gif': 'Изображение GIF',
|
||||
'image/webp': 'Изображение WebP',
|
||||
'image/svg+xml': 'Изображение SVG',
|
||||
'image/bmp': 'Изображение BMP',
|
||||
'image/tiff': 'Изображение TIFF',
|
||||
'image/avif': 'Изображение AVIF',
|
||||
'application/pdf': 'PDF документ',
|
||||
'application/msword': 'Документ Word',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
|
||||
'application/vnd.ms-excel': 'Таблица Excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
|
||||
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
|
||||
'application/zip': 'ZIP архив',
|
||||
'application/gzip': 'GZIP архив',
|
||||
'application/x-tar': 'TAR архив',
|
||||
'application/x-7z-compressed': '7z архив',
|
||||
'application/x-rar-compressed': 'RAR архив',
|
||||
'text/plain': 'Текстовый файл',
|
||||
'text/html': 'HTML файл',
|
||||
'text/css': 'CSS файл',
|
||||
'text/javascript': 'JavaScript файл',
|
||||
'application/json': 'JSON файл',
|
||||
'application/xml': 'XML файл',
|
||||
'application/x-yaml': 'YAML файл',
|
||||
'application/octet-stream': 'Бинарный файл',
|
||||
'application/x-msdos-program': 'Исполняемый файл',
|
||||
'inode/directory': 'Папка',
|
||||
}
|
||||
|
||||
export function formatMimeType(mime) {
|
||||
if (!mime) return 'Unknown'
|
||||
if (!mime) return 'Неизвестно'
|
||||
return mimeLabels[mime] || mime
|
||||
}
|
||||
|
||||
export function formatFileType(item) {
|
||||
if (item.type === 'folder') return 'Папка'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
if (mimeLabels[mime]) return mimeLabels[mime]
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
if (ext) return ext.toUpperCase()
|
||||
return 'Файл'
|
||||
}
|
||||
|
||||
export function getFileKind(item) {
|
||||
if (item.type === 'folder') return 'folder'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
|
||||
Reference in New Issue
Block a user