Files tab: multi-selection, drag-and-drop, keyboard shortcuts, custom confirm modal, SVG icons
This commit is contained in:
+626
-23
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user