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
+626 -23
View File
@@ -1,5 +1,10 @@
<script>
import { onMount } from 'svelte'
import FileTreeRow from './FileTreeRow.svelte'
import FileBreadcrumbs from './lib/FileBreadcrumbs.svelte'
import FilePreviewModal from './lib/FilePreviewModal.svelte'
import ConfirmModal from './lib/ConfirmModal.svelte'
import { onMount, onDestroy } from 'svelte'
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
// ===== Wails v2 API call helper =====
// In production: window['go']['main']['App']['MethodName'](...args)
@@ -39,6 +44,33 @@
let showCreateNote = false
let newNoteTitle = ''
let loading = true
let importing = false
let importSummary = null
let showImportDialog = false
let pendingImportPath = ''
let pendingImportMode = 'copy'
let treeItems = []
let expanded = {}
let childrenMap = {}
let loadingFiles = false
let currentFolderId = null
let folderStack = []
let fileItems = []
let previewItem = null
let previewContent = ''
let previewLoading = false
let previewError = ''
let clipboard = { items: [], mode: 'copy' }
let selectedIds = []
let dragIds = []
let showConfirm = false
let confirmTitle = ''
let confirmMessage = ''
let confirmDanger = false
let confirmText = 'Удалить'
let confirmAction = null
let cancelAction = null
const tabs = [
{ id: 'overview', label: 'Обзор' },
@@ -49,6 +81,8 @@
{ id: 'activity', label: 'Активность' },
]
let unlistenDrop = null
// ===== Lifecycle =====
onMount(async () => {
try {
@@ -67,9 +101,23 @@
{ id: 'archive', label: 'Архив' },
]
}
// Listen for file drops from OS file manager.
if (window.runtime && window.runtime.EventsOn) {
window.runtime.EventsOn('files-dropped', onFilesDropped)
unlistenDrop = () => window.runtime.EventsOff('files-dropped')
}
window.addEventListener('keydown', handleKeydown)
loading = false
})
onDestroy(() => {
if (unlistenDrop) unlistenDrop()
window.removeEventListener('keydown', handleKeydown)
})
// ===== Section / Node selection =====
async function selectSection(id) {
selectedSection = id
@@ -96,6 +144,16 @@
files = []
actions = []
worklog = []
treeItems = []
expanded = {}
childrenMap = {}
fileItems = []
folderStack = []
currentFolderId = null
previewItem = null
previewContent = ''
selectedIds = []
dragIds = []
noteEditor = null
showCreateNode = false
showCreateNote = false
@@ -110,6 +168,296 @@
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
}
async function loadTree(nodeID) {
loadingFiles = true
try {
treeItems = await wailsCall('ListItems', nodeID) || []
} catch (e) {
treeItems = []
}
loadingFiles = false
}
// ===== Folder navigation =====
async function loadFolder(folderId) {
loadingFiles = true
try {
let items = await wailsCall('ListItems', folderId) || []
items.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return (a.name || '').localeCompare(b.name || '')
})
fileItems = items
} catch (e) {
fileItems = []
}
loadingFiles = false
}
async function navigateToFolder(folderId) {
if (!selectedNode) return
// Get the folder node name for breadcrumbs
try {
const node = await wailsCall('GetNodeDetail', folderId)
if (node) {
folderStack = [...folderStack, { id: folderId, name: node.title }]
}
} catch (e) {
folderStack = [...folderStack, { id: folderId, name: '...' }]
}
currentFolderId = folderId
await loadFolder(folderId)
}
function navigateBack() {
if (folderStack.length < 2) {
// Go back to root
folderStack = []
currentFolderId = null
loadFolder(selectedNode.id)
} else {
const target = folderStack[folderStack.length - 2]
folderStack = folderStack.slice(0, -1)
currentFolderId = target.id
loadFolder(target.id)
}
}
function navigateToBreadcrumb(index) {
const target = folderStack[index]
folderStack = folderStack.slice(0, index + 1)
currentFolderId = target.id
loadFolder(target.id)
}
// ===== File preview =====
async function openPreview(item) {
previewItem = item
previewContent = ''
previewError = ''
previewLoading = true
try {
if (needsBase64Preview(item)) {
previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
} else if (needsTextPreview(item)) {
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
}
} catch (e) {
previewError = String(e)
}
previewLoading = false
}
function closePreview() {
previewItem = null
previewContent = ''
previewError = ''
}
// ===== File operations =====
async function createFile() {
const name = prompt('Введите имя файла:')
if (!name || !name.trim()) return
try {
const parentId = currentFolderId || selectedNode.id
await wailsCall('CreateEmptyFile', parentId, name.trim())
await loadFolder(parentId)
} catch (e) {
error = String(e)
}
}
async function duplicateItem(id) {
try {
await wailsCall('DuplicateNode', id)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
} catch (e) {
error = String(e)
}
}
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)
}
}
function cutItem(id) {
clipboard = { items: [id], mode: 'cut' }
}
function copyItem(id) {
clipboard = { items: [id], mode: 'copy' }
}
async function pasteItem() {
if (clipboard.items.length === 0) return
const targetId = currentFolderId || selectedNode.id
try {
if (clipboard.mode === 'copy') {
for (const id of clipboard.items) {
await wailsCall('DuplicateNode', id)
}
} else {
for (const id of clipboard.items) {
await wailsCall('MoveNode', id, targetId)
}
}
clipboard = { items: [], mode: 'copy' }
await loadFolder(targetId)
} catch (e) {
error = String(e)
}
}
// ===== Selection =====
function toggleSelection(id) {
if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter(x => x !== id)
} else {
selectedIds = [...selectedIds, id]
}
}
function selectOne(id) {
selectedIds = [id]
}
function selectAll() {
selectedIds = fileItems.map(x => x.id)
}
function clearSelection() {
selectedIds = []
}
function getTargetIds(ids) {
return ids.length > 0 ? ids : fileItems.map(x => x.id)
}
async function deleteSelected() {
const ids = getTargetIds(selectedIds)
const label = ids.length === 1 && fileItems.find(x => x.id === ids[0])?.type === 'folder' ? 'папку' : `файлов (${ids.length})`
openConfirm({
title: 'Удаление',
message: `Удалить ${label}?`,
confirmText: 'Удалить',
danger: true,
onConfirm: async () => {
for (const id of ids) {
try {
await wailsCall('DeleteFileOrFolder', id)
} catch (e) { error = String(e) }
}
selectedIds = []
const reloadId = currentFolderId || selectedNode.id
await loadFolder(reloadId)
}
})
}
function cutSelected() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'cut' }
selectedIds = []
}
function copySelected() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'copy' }
selectedIds = []
}
// ===== Drag-and-drop =====
function onDragStart(e, id) {
const ids = selectedIds.includes(id) ? selectedIds : [id]
dragIds = ids
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', ids.join(','))
}
function onDragOver(e, folderId) {
const item = fileItems.find(x => x.id === folderId)
if (item && item.type === 'folder') {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
}
async function onDrop(e, folderId) {
e.preventDefault()
if (dragIds.length === 0) return
for (const id of dragIds) {
try {
await wailsCall('MoveNode', id, folderId)
} catch (e) { error = String(e) }
}
dragIds = []
selectedIds = []
await loadFolder(currentFolderId || selectedNode.id)
}
// ===== Keyboard =====
function handleKeydown(e) {
if (activeTab !== 'files') return
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() }
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() }
}
}
// ===== Confirm modal =====
function openConfirm(opts) {
confirmTitle = opts.title || 'Подтверждение'
confirmMessage = opts.message || ''
confirmDanger = opts.danger !== undefined ? opts.danger : true
confirmText = opts.confirmText || 'Удалить'
confirmAction = opts.onConfirm || null
cancelAction = opts.onCancel || null
showConfirm = true
}
function closeConfirm() {
showConfirm = false
confirmAction = null
cancelAction = null
}
function handleConfirm() {
if (confirmAction) confirmAction()
closeConfirm()
}
function handleCancel() {
if (cancelAction) cancelAction()
closeConfirm()
}
// ===== Node creation =====
function openCreateNode() {
showCreateNode = true
@@ -148,8 +496,21 @@
async function openNote(note) {
if (noteEditor && noteEditor.dirty) {
if (!confirm('Несохранённые изменения. Закрыть?')) return
openConfirm({
title: 'Несохранённые изменения',
message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
confirmText: 'Закрыть',
danger: false,
onConfirm: async () => {
await doOpenNote(note)
}
})
return
}
await doOpenNote(note)
}
async function doOpenNote(note) {
try {
const content = await wailsCall('ReadNote', note.id)
noteEditor = { id: note.id, title: note.title, content: content || '', dirty: false }
@@ -160,7 +521,14 @@
function closeNoteEditor() {
if (noteEditor && noteEditor.dirty) {
if (!confirm('Несохранённые изменения. Закрыть?')) return
openConfirm({
title: 'Несохранённые изменения',
message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
confirmText: 'Закрыть',
danger: false,
onConfirm: () => { noteEditor = null }
})
return
}
noteEditor = null
}
@@ -194,6 +562,94 @@
worklogMinutes = ''
}
// ===== Files =====
async function addFile() {
const path = await wailsCall('PickFile')
if (!path) return
await startImport(selectedNode.id, path)
}
async function addFolder() {
const path = await wailsCall('PickDirectory')
if (!path) return
await startImport(selectedNode.id, path)
}
async function startImport(nodeID, sourcePath) {
importing = true
try {
const summary = await wailsCall('PreviewImport', sourcePath)
importSummary = summary
pendingImportPath = sourcePath
showImportDialog = true
} catch (e) {
error = String(e)
}
importing = false
}
async function confirmImport(mode) {
try {
const result = mode === 'copy'
? await wailsCall('AddPathCopy', selectedNode.id, pendingImportPath)
: await wailsCall('AddPathLink', selectedNode.id, pendingImportPath)
showImportDialog = false
importSummary = null
folderStack = []
currentFolderId = null
await Promise.all([
loadTabData(selectedNode.id),
loadFolder(selectedNode.id),
])
} catch (e) {
error = String(e)
}
}
function cancelImport() {
showImportDialog = false
importSummary = null
}
async function deleteFile({ id, type }) {
const label = type === 'folder' ? 'папку' : 'файл'
openConfirm({
title: 'Удаление',
message: `Удалить ${label}?`,
confirmText: 'Удалить',
danger: true,
onConfirm: async () => {
try {
await wailsCall('DeleteFileOrFolder', id)
files = files.filter(f => f.nodeId !== id)
const reloadId = currentFolderId || selectedNode.id
await loadFolder(reloadId)
} catch (e) {
error = String(e)
}
}
})
}
async function openSelectedFile(fileID) {
try {
await wailsCall('OpenFile', fileID)
} catch (e) {
error = String(e)
}
}
// ===== Drag-and-drop =====
async function onFilesDropped(paths) {
if (!paths || paths.length === 0) return
if (!selectedNode) {
error = 'Сначала выберите дело для добавления файлов'
return
}
const path = paths[0]
await startImport(selectedNode.id, path)
}
// ===== Helpers =====
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
function formatDate(str) {
@@ -252,7 +708,12 @@
{#if error}
<div class="error-banner" on:click={() => error = ''}>
{error} <span class="dismiss"></span>
{error}
<button class="dismiss-btn" on:click|stopPropagation={() => error = ''} aria-label="Dismiss">
<svg width="14" height="14" 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>
{/if}
@@ -275,7 +736,7 @@
<!-- Tabs -->
<div class="tabs">
{#each tabs as tab}
<button class={tabClass(tab.id)} on:click={() => activeTab = tab.id}>{tab.label}</button>
<button class={tabClass(tab.id)} on:click={() => { activeTab = tab.id; if (tab.id === 'files' && selectedNode && fileItems.length === 0 && !currentFolderId) loadFolder(selectedNode.id) }}>{tab.label}</button>
{/each}
</div>
<div class="tab-content">
@@ -288,10 +749,22 @@
<div class="meta-item"><span class="meta-label">Создано</span><span>{formatDate(selectedNode.createdAt)}</span></div>
</div>
<div class="quick-actions">
<button class="qa-btn" on:click={() => { activeTab = 'notes'; openCreateNote() }}>✏️ Новая заметка</button>
<button class="qa-btn" disabled title="Следующий этап">📎 Добавить файл</button>
<button class="qa-btn" disabled title="Следующий этап">⚡ Добавить действие</button>
<button class="qa-btn" on:click={() => activeTab = 'worklog'}>🕐 Записать время</button>
<button class="qa-btn" on:click={() => { activeTab = 'notes'; openCreateNote() }}>
<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="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="qa-btn" on:click={() => { activeTab = 'files'; addFile() }}>
<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="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
Добавить файл
</button>
<button class="qa-btn" disabled title="Следующий этап">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Добавить действие
</button>
<button class="qa-btn" on:click={() => activeTab = 'worklog'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Записать время
</button>
</div>
{#if notes.length > 0}
<div class="recent-section">
@@ -343,16 +816,96 @@
</div>
{:else if activeTab === 'files'}
<div class="empty-state">
<p>Нет файлов</p>
<p class="hint">Добавьте документы, скриншоты или папку с материалами.</p>
<div class="empty-actions">
<button class="btn" disabled>+ Добавить файл</button>
<button class="btn" disabled>+ Добавить папку</button>
<div class="files-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={addFile} disabled={importing}>+ Добавить файл</button>
<button class="btn" on:click={addFolder} disabled={importing}>+ Добавить папку</button>
<button class="btn" on:click={createFile}>+ Новый файл</button>
{#if clipboard.items.length > 0}
<button class="btn" on:click={pasteItem}>Вставить {clipboard.items.length}</button>
{/if}
</div>
<p class="empty-note">Полноценная работа с файлами — следующий этап.</p>
{#if loadingFiles}
<div class="empty-state">
<p>Загрузка...</p>
</div>
{:else}
{#if folderStack.length > 0}
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
const i = e.detail
if (i === 0) {
folderStack = []
currentFolderId = null
loadFolder(selectedNode.id)
} else {
navigateToBreadcrumb(i - 1)
}
}}/>
<button class="btn btn-sm back-btn" on:click={navigateBack}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back
</button>
{:else}
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
{/if}
{#if fileItems.length === 0}
<div class="empty-state">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<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"/>
</svg>
</div>
<p>{folderStack.length > 0 ? 'В этой папке пока нет файлов' : 'В этом проекте пока нет файлов'}</p>
<p class="hint">Добавьте файл или папку, чтобы сохранить материалы проекта.</p>
<div class="empty-actions">
<button class="btn btn-primary" on:click={addFile}>Добавить файл</button>
<button class="btn" on:click={addFolder}>Добавить папку</button>
</div>
</div>
{:else}
<div class="file-list">
{#each fileItems as item (item.id)}
<FileTreeRow
{item}
selected={selectedIds.includes(item.id)}
{onDragStart}
{onDragOver}
{onDrop}
on:navigate={(e) => navigateToFolder(e.detail)}
on:preview={(e) => openPreview(e.detail)}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
on:delete={(e) => deleteFile(e.detail)}
on:rename={(e) => renameItem(e.detail.id)}
on:duplicate={(e) => duplicateItem(e.detail)}
on:cut={(e) => cutItem(e.detail)}
on:copy={(e) => copyItem(e.detail)}
on:selectOne={(e) => selectOne(e.detail)}
on:toggleSelect={(e) => toggleSelection(e.detail)}
/>
{/each}
</div>
{/if}
{/if}
{#if importing && !showImportDialog}
<div class="empty-state"><p>Сканирование...</p></div>
{/if}
</div>
{#if previewItem}
<FilePreviewModal
item={previewItem}
content={previewContent}
loading={previewLoading}
error={previewError}
on:close={closePreview}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
/>
{/if}
{:else if activeTab === 'actions'}
{#if actions.length === 0}
<div class="empty-state"><p>Действий пока нет</p></div>
@@ -429,6 +982,41 @@
</div>
</div>
{/if}
{#if showImportDialog && importSummary}
<div class="modal-overlay" on:click|self={cancelImport}>
<div class="modal">
<h3>Добавить в «{selectedNode ? selectedNode.title : ''}»</h3>
<div class="import-summary">
<div class="summary-row"><span>Файлов:</span><span>{importSummary.files}</span></div>
<div class="summary-row"><span>Папок:</span><span>{importSummary.folders}</span></div>
<div class="summary-row"><span>Размер:</span><span>{(importSummary.totalBytes / 1024).toFixed(1)} KB</span></div>
{#if importSummary.isDangerous}
<div class="summary-warn">
<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="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
{importSummary.dangerReason}
</div>
{/if}
</div>
<div class="modal-actions">
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>Скопировать</button>
<button class="btn" on:click={() => confirmImport('link')}>Привязать</button>
<button class="btn" on:click={cancelImport}>Отмена</button>
</div>
</div>
</div>
{/if}
{#if showConfirm}
<ConfirmModal
title={confirmTitle}
message={confirmMessage}
confirmText={confirmText}
danger={confirmDanger}
on:confirm={handleConfirm}
on:cancel={handleCancel}
/>
{/if}
</main>
</div>
@@ -457,8 +1045,9 @@
.crumb { font-size: 14px; font-weight: 500; }
.crumb.placeholder { color: #666; }
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
.error-banner { background: #3a2222; color: #ff8888; padding: 8px 24px; font-size: 12px; border-bottom: 1px solid #4a2222; flex-shrink: 0; cursor: pointer; display: flex; justify-content: space-between; }
.dismiss { opacity: 0.6; }
.error-banner { background: #3a2222; color: #ff8888; padding: 8px 24px; font-size: 12px; border-bottom: 1px solid #4a2222; flex-shrink: 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.dismiss-btn { background: none; border: none; color: #ff6666; cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: 2px; }
.dismiss-btn:hover { color: #ff4444; }
/* Tabs */
.tabs { display: flex; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; padding: 0 24px; }
@@ -484,7 +1073,7 @@
.meta-item { background: #1a1a28; padding: 12px 16px; border-radius: 8px; }
.meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
.quick-actions { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
.qa-btn { padding: 10px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; }
.qa-btn { padding: 10px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.qa-btn:hover { background: #222233; }
.qa-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.recent-section { margin-bottom: 24px; }
@@ -522,10 +1111,11 @@
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
/* Empty states */
.empty-state { padding: 48px 24px; text-align: center; }
.empty-state p { color: #666; margin-bottom: 8px; }
.hint { font-size: 13px; color: #555; }
.empty-actions { display: flex; gap: 8px; justify-content: center; margin: 16px 0; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
.empty-state p { margin: 0; font-size: 14px; color: #666; }
.empty-state .empty-icon { margin-bottom: 12px; color: #444; }
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
.empty-state .empty-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
.empty-note { font-size: 12px; color: #444; margin-top: 16px; }
/* Welcome */
@@ -556,4 +1146,17 @@
.btn-primary:hover { background: #4f46e5; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
.btn-danger:hover { background: #3a2222; }
/* Files tab */
.files-tab { padding: 20px; }
.files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
.file-list { display: flex; flex-direction: column; }
.back-btn { margin-bottom: 4px; display: inline-flex; align-items: center; gap: 4px; }
/* Import summary */
.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; }
</style>
+346
View File
@@ -0,0 +1,346 @@
<script>
import { createEventDispatcher } from 'svelte'
import FileIcon from './lib/FileIcon.svelte'
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
export let item
export let selected = false
export let onDragStart
export let onDragOver
export let onDrop
const dispatch = createEventDispatcher()
const kind = getFileKind(item)
const isFolder = item.type === 'folder'
let menuOpen = false
let clickTimer = null
function handleClick(e) {
if (e.ctrlKey || e.metaKey) {
dispatch('toggleSelect', item.id)
} else if (e.shiftKey) {
dispatch('rangeSelect', item.id)
} else {
if (clickTimer) {
clearTimeout(clickTimer)
clickTimer = null
// Double click
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
} 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)
}
}, 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 handleDelete() {
dispatch('delete', { id: item.id, type: item.type })
}
function handleRename() {
menuOpen = false
dispatch('rename', { id: item.id, name: item.name })
}
function handleDuplicate() {
menuOpen = false
dispatch('duplicate', item.id)
}
function handleCut() {
menuOpen = false
dispatch('cut', item.id)
}
function handleCopy() {
menuOpen = false
dispatch('copy', item.id)
}
function toggleMenu() {
menuOpen = !menuOpen
}
function closeMenu() {
menuOpen = false
}
function handleDragStart(e) {
if (onDragStart) onDragStart(e, item.id)
}
function handleDragOver(e) {
if (onDragOver && isFolder) onDragOver(e, item.id)
}
function handleDrop(e) {
if (onDrop && isFolder) onDrop(e, item.id)
}
</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:dragstart={handleDragStart}
on:dragover={handleDragOver}
on:drop={handleDrop}
aria-label={isFolder ? `Folder ${item.name}` : `File ${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>{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">
<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">
<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>
{:else}
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
<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}>
<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">
<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>
</div>
{#if menuOpen}
<!-- 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>
</div>
{/if}
<style>
.file-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 6px;
cursor: default;
transition: background 0.12s;
min-height: 52px;
user-select: none;
}
.file-row:hover {
background: #1e1e30;
}
.file-row--selected {
background: #1e1e3a;
outline: 1px solid #3a3a6c;
}
.file-row--selected:hover {
background: #252545;
}
.file-row:focus-visible {
outline: 2px solid #5588ff;
outline-offset: -2px;
}
.file-row-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: #888;
}
.file-row-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.file-row-name {
font-size: 13px;
color: #ddd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.file-row-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
.meta-sep {
color: #444;
}
.file-row-actions {
display: flex;
gap: 2px;
align-items: center;
opacity: 0;
transition: opacity 0.15s ease;
flex-shrink: 0;
}
.file-row:hover .file-row-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;
}
.menu-backdrop {
position: fixed;
inset: 0;
z-index: 99;
}
.menu {
position: absolute;
right: 12px;
margin-top: 4px;
background: #1a1a28;
border: 1px solid #2a2a3c;
border-radius: 6px;
padding: 4px;
z-index: 100;
min-width: 140px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.menu-item {
display: block;
width: 100%;
padding: 6px 12px;
border: none;
background: transparent;
color: #ccc;
font-size: 12px;
text-align: left;
cursor: pointer;
border-radius: 4px;
font-family: inherit;
}
.menu-item:hover {
background: #2a2a3c;
}
.menu-item:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+16
View File
@@ -15,6 +15,22 @@ export const readNote = (noteID) => App.ReadNote(noteID)
export const saveNote = (noteID, content) => App.SaveNote(noteID, content)
export const listFiles = (nodeID) => App.ListFiles(nodeID)
export const listItems = (nodeID) => App.ListItems(nodeID)
export const addPathCopy = (nodeID, sourcePath) => App.AddPathCopy(nodeID, sourcePath)
export const addPathLink = (nodeID, sourcePath) => App.AddPathLink(nodeID, sourcePath)
export const deleteFileOrFolder = (nodeID) => App.DeleteFileOrFolder(nodeID)
export const previewImport = (sourcePath) => App.PreviewImport(sourcePath)
export const pickFile = () => App.PickFile()
export const pickFiles = () => App.PickFiles()
export const pickDirectory = () => App.PickDirectory()
export const openFile = (id) => App.OpenFile(id)
export const readFileText = (id) => App.ReadFileText(id)
export const getFileBase64 = (id) => App.GetFileBase64(id)
export const createEmptyFile = (parentID, filename) => App.CreateEmptyFile(parentID, filename)
export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
export const listActions = (nodeID) => App.ListActions(nodeID)
export const runAction = (id) => App.RunAction(id)
+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)
}
+16
View File
@@ -46,6 +46,22 @@ export function ListFiles(arg1) {
return window['go']['main']['App']['ListFiles'](arg1);
}
export function AddPathCopy(arg1, arg2) {
return window['go']['main']['App']['AddPathCopy'](arg1, arg2);
}
export function AddPathLink(arg1, arg2) {
return window['go']['main']['App']['AddPathLink'](arg1, arg2);
}
export function DeleteFileOrFolder(arg1) {
return window['go']['main']['App']['DeleteFileOrFolder'](arg1);
}
export function PreviewImport(arg1) {
return window['go']['main']['App']['PreviewImport'](arg1);
}
export function ListActions(arg1) {
return window['go']['main']['App']['ListActions'](arg1);
}