1434 lines
56 KiB
Svelte
1434 lines
56 KiB
Svelte
<script>
|
||
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)
|
||
// In dev without Wails: fallback to mock data
|
||
function wailsCall(method, ...args) {
|
||
try {
|
||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||
const fn = window['go']['main']['App'][method]
|
||
if (typeof fn === 'function') {
|
||
return fn(...args)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Wails call error:', method, e)
|
||
}
|
||
return Promise.reject(new Error('Wails not connected: ' + method))
|
||
}
|
||
|
||
// ===== State =====
|
||
let sections = []
|
||
let nodes = []
|
||
let todayDashboard = null
|
||
let version = ''
|
||
let error = ''
|
||
let selectedSection = ''
|
||
let selectedNode = null
|
||
let activeTab = 'overview'
|
||
let notes = []
|
||
let noteEditor = null
|
||
let files = []
|
||
let actions = []
|
||
let worklog = []
|
||
let worklogMinutes = ''
|
||
let worklogSummary = ''
|
||
let showCreateNode = false
|
||
let newNodeTitle = ''
|
||
let newNodeSection = 'clients'
|
||
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
|
||
|
||
let showRename = false
|
||
let renameId = ''
|
||
let renameValue = ''
|
||
let renameError = ''
|
||
|
||
const tabs = [
|
||
{ id: 'overview', label: 'Обзор' },
|
||
{ id: 'notes', label: 'Заметки' },
|
||
{ id: 'files', label: 'Файлы' },
|
||
{ id: 'actions', label: 'Действия' },
|
||
{ id: 'worklog', label: 'Журнал' },
|
||
{ id: 'activity', label: 'Активность' },
|
||
]
|
||
|
||
let unlistenDrop = null
|
||
|
||
// ===== Lifecycle =====
|
||
onMount(async () => {
|
||
try {
|
||
version = await wailsCall('VerstakVersion') || 'verstak-gui/v2'
|
||
sections = await wailsCall('ListSections') || []
|
||
} catch (e) {
|
||
error = String(e)
|
||
// Fallback: show sections from known list
|
||
sections = [
|
||
{ id: 'today', label: 'Сегодня' },
|
||
{ id: 'inbox', label: 'Неразобранное' },
|
||
{ id: 'clients', label: 'Клиенты' },
|
||
{ id: 'projects', label: 'Проекты' },
|
||
{ id: 'recipes', label: 'Рецепты' },
|
||
{ id: 'documents', label: 'Документы' },
|
||
{ 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
|
||
selectedNode = null
|
||
activeTab = 'overview'
|
||
notes = []
|
||
files = []
|
||
actions = []
|
||
worklog = []
|
||
showCreateNode = false
|
||
error = ''
|
||
todayDashboard = null
|
||
nodes = []
|
||
try {
|
||
if (id === 'today') {
|
||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||
} else {
|
||
nodes = await wailsCall('ListNodesBySection', id) || []
|
||
}
|
||
} catch (e) {
|
||
error = String(e)
|
||
nodes = []
|
||
todayDashboard = { cases: [] }
|
||
}
|
||
}
|
||
|
||
async function selectNode(node) {
|
||
selectedNode = node
|
||
activeTab = 'overview'
|
||
notes = []
|
||
files = []
|
||
actions = []
|
||
worklog = []
|
||
treeItems = []
|
||
expanded = {}
|
||
childrenMap = {}
|
||
fileItems = []
|
||
folderStack = []
|
||
currentFolderId = null
|
||
previewItem = null
|
||
previewContent = ''
|
||
selectedIds = []
|
||
dragIds = []
|
||
noteEditor = null
|
||
showCreateNode = false
|
||
showCreateNote = false
|
||
error = ''
|
||
await loadTabData(node.id)
|
||
}
|
||
|
||
async function loadTabData(nodeID) {
|
||
try { notes = await wailsCall('ListNotes', nodeID) || [] } catch(e) {}
|
||
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
||
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
||
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 item = fileItems.find(x => x.id === id)
|
||
if (item) openRename(item.id, item.name)
|
||
}
|
||
|
||
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 rangeSelect(id) {
|
||
if (fileItems.length === 0) return
|
||
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
|
||
const lastIdx = fileItems.findIndex(x => x.id === lastId)
|
||
const curIdx = fileItems.findIndex(x => x.id === id)
|
||
if (lastIdx === -1 || curIdx === -1) return
|
||
const start = Math.min(lastIdx, curIdx)
|
||
const end = Math.max(lastIdx, curIdx)
|
||
const range = fileItems.slice(start, end + 1).map(x => x.id)
|
||
const set = new Set(selectedIds)
|
||
range.forEach(x => set.add(x))
|
||
selectedIds = [...set]
|
||
}
|
||
|
||
function clearSelection() {
|
||
selectedIds = []
|
||
}
|
||
|
||
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.key === 'C') { e.preventDefault(); copySelected() }
|
||
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
|
||
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
|
||
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
|
||
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
|
||
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
openSelected()
|
||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
||
// Backspace without selection → navigate up
|
||
if (e.key === 'Backspace' && folderStack.length > 0) {
|
||
e.preventDefault()
|
||
navigateBack()
|
||
return
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
if (previewItem) { closePreview(); return }
|
||
if (selectedIds.length > 0) { clearSelection(); return }
|
||
} else if (e.key === 'F2') {
|
||
e.preventDefault()
|
||
openRenameForSelection()
|
||
}
|
||
}
|
||
|
||
function openSelected() {
|
||
if (selectedIds.length === 1) {
|
||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||
if (item) {
|
||
if (item.type === 'folder') {
|
||
navigateToFolder(item.id)
|
||
} else {
|
||
openPreview(item)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function openSelectedExternal() {
|
||
if (selectedIds.length === 1) {
|
||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||
if (item && item.fileId) {
|
||
wailsCall('OpenFile', item.fileId)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== Rename modal =====
|
||
|
||
function openRename(id, currentName) {
|
||
renameId = id
|
||
renameValue = currentName
|
||
renameError = ''
|
||
showRename = true
|
||
}
|
||
|
||
function openRenameForSelection() {
|
||
if (selectedIds.length === 1) {
|
||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||
if (item) {
|
||
openRename(item.id, item.name)
|
||
}
|
||
}
|
||
}
|
||
|
||
async function submitRename() {
|
||
const name = renameValue.trim()
|
||
if (!name) { renameError = 'Имя не может быть пустым'; return }
|
||
// Validate name via backend
|
||
try {
|
||
await wailsCall('ValidateName', name)
|
||
} catch (e) {
|
||
renameError = 'Недопустимое имя'
|
||
return
|
||
}
|
||
showRename = false
|
||
renameId = ''
|
||
try {
|
||
await wailsCall('RenameNode', renameId, name)
|
||
const parentId = currentFolderId || selectedNode.id
|
||
await loadFolder(parentId)
|
||
} catch (e) {
|
||
error = String(e)
|
||
}
|
||
}
|
||
|
||
function cancelRename() {
|
||
showRename = false
|
||
renameId = ''
|
||
renameValue = ''
|
||
renameError = ''
|
||
}
|
||
|
||
function onRenameKeydown(e) {
|
||
if (e.key === 'Enter') submitRename()
|
||
else renameError = ''
|
||
}
|
||
|
||
// ===== Confirm modal =====
|
||
|
||
function openConfirm(opts) {
|
||
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
|
||
newNodeTitle = ''
|
||
newNodeSection = selectedSection || 'clients'
|
||
}
|
||
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
|
||
async function submitCreateNode() {
|
||
if (!newNodeTitle.trim()) return
|
||
try {
|
||
const node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
|
||
showCreateNode = false
|
||
newNodeTitle = ''
|
||
await selectSection(newNodeSection)
|
||
} catch (e) { error = String(e) }
|
||
}
|
||
|
||
// ===== Notes =====
|
||
function openCreateNote() { showCreateNote = true; newNoteTitle = '' }
|
||
function cancelCreateNote() { showCreateNote = false; newNoteTitle = '' }
|
||
async function submitCreateNote() {
|
||
if (!newNoteTitle.trim() || !selectedNode) return
|
||
try {
|
||
const note = await wailsCall('CreateNote', selectedNode.id, newNoteTitle.trim())
|
||
notes = [...notes, (note && note.id) ? note : { id: Date.now().toString(), title: newNoteTitle.trim(), createdAt: new Date().toISOString() }]
|
||
showCreateNote = false
|
||
newNoteTitle = ''
|
||
} catch (e) {
|
||
// Fallback: create note locally
|
||
const newNote = { id: Date.now().toString(), title: newNoteTitle.trim(), createdAt: new Date().toISOString() }
|
||
notes = [...notes, newNote]
|
||
showCreateNote = false
|
||
newNoteTitle = ''
|
||
}
|
||
}
|
||
|
||
async function openNote(note) {
|
||
if (noteEditor && noteEditor.dirty) {
|
||
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 }
|
||
} catch (e) {
|
||
noteEditor = { id: note.id, title: note.title, content: '# ' + note.title + '\n\n', dirty: false }
|
||
}
|
||
}
|
||
|
||
function closeNoteEditor() {
|
||
if (noteEditor && noteEditor.dirty) {
|
||
openConfirm({
|
||
title: 'Несохранённые изменения',
|
||
message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
|
||
confirmText: 'Закрыть',
|
||
danger: false,
|
||
onConfirm: () => { noteEditor = null }
|
||
})
|
||
return
|
||
}
|
||
noteEditor = null
|
||
}
|
||
|
||
function updateNoteContent(e) {
|
||
if (noteEditor) { noteEditor.content = e.target.value; noteEditor.dirty = true }
|
||
}
|
||
|
||
async function saveCurrentNote() {
|
||
if (!noteEditor) return
|
||
try {
|
||
await wailsCall('SaveNote', noteEditor.id, noteEditor.content)
|
||
noteEditor.dirty = false
|
||
} catch (e) {
|
||
// Saved locally only
|
||
noteEditor.dirty = false
|
||
}
|
||
}
|
||
|
||
// ===== Worklog =====
|
||
async function submitWorklog() {
|
||
const mins = parseInt(worklogMinutes, 10)
|
||
if (!worklogSummary.trim() || isNaN(mins) || mins <= 0 || !selectedNode) return
|
||
try {
|
||
const entry = await wailsCall('CreateWorklog', selectedNode.id, worklogSummary.trim(), mins)
|
||
worklog = [...worklog, (entry && entry.id) ? entry : { id: Date.now().toString(), nodeId: selectedNode.id, summary: worklogSummary.trim(), minutes: mins, createdAt: new Date().toISOString() }]
|
||
} catch (e) {
|
||
worklog = [...worklog, { id: Date.now().toString(), nodeId: selectedNode.id, summary: worklogSummary.trim(), minutes: mins, createdAt: new Date().toISOString() }]
|
||
}
|
||
worklogSummary = ''
|
||
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 eventLabel(type) {
|
||
const labels = {
|
||
'note_created': 'Заметка создана',
|
||
'note_updated': 'Заметка изменена',
|
||
'file_added': 'Файл добавлен',
|
||
'file_deleted': 'Файл удалён',
|
||
'file_renamed': 'Файл переименован',
|
||
'file_copied': 'Файл скопирован',
|
||
'file_moved': 'Файл перемещён',
|
||
'folder_added': 'Папка добавлена',
|
||
'folder_deleted': 'Папка удалена',
|
||
'folder_renamed': 'Папка переименована',
|
||
'node_created': 'Дело создано',
|
||
'node_updated': 'Дело изменено',
|
||
}
|
||
return labels[type] || type
|
||
}
|
||
function eventIcon(type) {
|
||
if (type === 'note_created' || type === 'file_added' || type === 'folder_added' || type === 'node_created') return '+'
|
||
if (type === 'file_deleted' || type === 'folder_deleted') return '×'
|
||
if (type === 'file_renamed' || type === 'folder_renamed' || type === 'note_updated' || type === 'node_updated') return '~'
|
||
if (type === 'file_copied') return '⧉'
|
||
if (type === 'file_moved') return '→'
|
||
return '•'
|
||
}
|
||
function formatTime(str) {
|
||
if (!str) return ''
|
||
try { return new Date(str).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' }
|
||
}
|
||
function formatDate(str) {
|
||
if (!str) return ''
|
||
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
|
||
}
|
||
function nodeKindLabel(kind) {
|
||
const labels = { 'project': 'Проект', 'client': 'Клиент', 'document': 'Документ', 'recipe': 'Рецепт', 'archive': 'Архив', 'case': 'Дело' }
|
||
return labels[kind] || kind || 'Дело'
|
||
}
|
||
function pluralize(n, one, few, many) {
|
||
n = Math.abs(n) % 100
|
||
if (n >= 5 && n <= 20) return many
|
||
n %= 10
|
||
if (n === 1) return one
|
||
if (n >= 2 && n <= 4) return few
|
||
return many
|
||
}
|
||
async function openNodeById(id) {
|
||
try {
|
||
const node = await wailsCall('GetNodeDetail', id)
|
||
if (node) selectNode(node)
|
||
} catch (e) {
|
||
error = String(e)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="app">
|
||
<!-- Sidebar -->
|
||
<aside class="sidebar">
|
||
<div class="sidebar-brand">
|
||
<span class="logo">⚒</span>
|
||
<span class="brand-name">Верстак</span>
|
||
</div>
|
||
<nav class="sidebar-nav">
|
||
<div class="nav-group">
|
||
<div class="nav-label">Разделы</div>
|
||
{#each sections as section}
|
||
<button class="nav-item {selectedSection === section.id ? 'selected' : ''}"
|
||
on:click={() => selectSection(section.id)}>
|
||
{section.label}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox'}
|
||
<div class="nav-group">
|
||
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
|
||
{#each nodes as node}
|
||
<button class="nav-item {selectedNode && selectedNode.id === node.id ? 'selected' : ''}"
|
||
on:click={() => selectNode(node)}>
|
||
{node.title}
|
||
</button>
|
||
{/each}
|
||
{#if nodes.length === 0}<div class="nav-empty">Нет дел</div>{/if}
|
||
</div>
|
||
{/if}
|
||
</nav>
|
||
<div class="sidebar-footer"><span class="version">{version}</span></div>
|
||
</aside>
|
||
|
||
<!-- Main -->
|
||
<main class="main">
|
||
<header class="header">
|
||
<div class="header-left">
|
||
{#if selectedNode}
|
||
<span class="crumb">{selectedNode.title}</span>
|
||
<span class="crumb-type">{selectedNode.type}</span>
|
||
{:else if selectedSection}
|
||
<span class="crumb">{#each sections as s}{s.id === selectedSection ? s.label : ''}{/each}</span>
|
||
{:else}
|
||
<span class="crumb placeholder">Выберите раздел или дело</span>
|
||
{/if}
|
||
</div>
|
||
</header>
|
||
|
||
{#if error}
|
||
<div class="error-banner" on:click={() => error = ''}>
|
||
{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}
|
||
|
||
{#if noteEditor}
|
||
<!-- Note editor -->
|
||
<div class="note-editor">
|
||
<div class="note-editor-header">
|
||
<span class="note-title">{noteEditor.title}</span>
|
||
{#if noteEditor.dirty}<span class="dirty-mark">●</span>{/if}
|
||
<div class="note-editor-actions">
|
||
<button class="btn btn-primary" on:click={saveCurrentNote}>Сохранить</button>
|
||
<button class="btn" on:click={closeNoteEditor}>Закрыть</button>
|
||
</div>
|
||
</div>
|
||
<textarea class="note-textarea" bind:value={noteEditor.content}
|
||
on:input={updateNoteContent} placeholder="Начните писать..."></textarea>
|
||
</div>
|
||
|
||
{:else if selectedNode}
|
||
<!-- Tabs -->
|
||
<div class="tabs">
|
||
{#each tabs as tab}
|
||
<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">
|
||
{#if activeTab === 'overview'}
|
||
<div class="overview">
|
||
<h2>{selectedNode.title}</h2>
|
||
<div class="meta-grid">
|
||
<div class="meta-item"><span class="meta-label">Тип</span><span>{selectedNode.type}</span></div>
|
||
<div class="meta-item"><span class="meta-label">Раздел</span><span>{selectedNode.section || '—'}</span></div>
|
||
<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() }}>
|
||
<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">
|
||
<h3>Последние заметки</h3>
|
||
{#each notes.slice(0, 5) as note}
|
||
<div class="recent-note" on:click={() => openNote(note)}>
|
||
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{#if worklog.length > 0}
|
||
<div class="recent-section">
|
||
<h3>Последние записи</h3>
|
||
{#each worklog.slice(0, 3) as e}
|
||
<div class="recent-entry">{e.summary} ({e.minutes} мин)</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{:else if activeTab === 'notes'}
|
||
<div class="notes-tab">
|
||
<div class="tab-toolbar">
|
||
<button class="btn btn-primary" on:click={openCreateNote}>+ Добавить заметку</button>
|
||
</div>
|
||
{#if showCreateNote}
|
||
<div class="create-form">
|
||
<input type="text" placeholder="Название заметки" bind:value={newNoteTitle}
|
||
on:keydown={(e) => e.key === 'Enter' && submitCreateNote()} />
|
||
<div class="form-actions">
|
||
<button class="btn btn-primary" on:click={submitCreateNote}>Создать</button>
|
||
<button class="btn" on:click={cancelCreateNote}>Отмена</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{#if notes.length === 0 && !showCreateNote}
|
||
<div class="empty-state"><p>Нет заметок</p><p class="hint">Создайте первую заметку для этого дела.</p></div>
|
||
{:else}
|
||
<div class="notes-list">
|
||
{#each notes as note}
|
||
<div class="note-card" on:click={() => openNote(note)}>
|
||
<div class="note-card-title">{note.title}</div>
|
||
<div class="note-card-date">{formatDate(note.createdAt)}</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{:else if activeTab === 'files'}
|
||
<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>
|
||
|
||
{#if loadingFiles}
|
||
<div class="empty-state">
|
||
<p>Загрузка...</p>
|
||
</div>
|
||
{:else}
|
||
{#if folderStack.length > 0}
|
||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...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: 'Файлы' }]}/>
|
||
{/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:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
||
on:delete={(e) => deleteFile(e.detail)}
|
||
on:rename={(e) => renameItem(e.detail.id)}
|
||
on:duplicate={(e) => duplicateItem(e.detail)}
|
||
on:cut={(e) => cutItem(e.detail)}
|
||
on:copy={(e) => copyItem(e.detail)}
|
||
on:selectOne={(e) => selectOne(e.detail)}
|
||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
||
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
||
/>
|
||
{/each}
|
||
</div>
|
||
{/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>
|
||
{:else}
|
||
{#each actions as action}
|
||
<div class="action-card">
|
||
<span>{action.title}</span><span class="action-type">{action.type}</span>
|
||
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
|
||
</div>
|
||
{/each}
|
||
{/if}
|
||
|
||
{:else if activeTab === 'worklog'}
|
||
<div class="worklog-tab">
|
||
<div class="worklog-form">
|
||
<input type="text" placeholder="Что сделано" bind:value={worklogSummary} />
|
||
<input type="number" placeholder="Мин" bind:value={worklogMinutes} min="1" />
|
||
<button class="btn btn-primary" on:click={submitWorklog}
|
||
disabled={!worklogSummary.trim() || !worklogMinutes}>Записать</button>
|
||
</div>
|
||
{#if worklog.length === 0}
|
||
<div class="empty-state"><p>Записей работы пока нет</p></div>
|
||
{:else}
|
||
{#each worklog as e}
|
||
<div class="worklog-entry">
|
||
<div>{e.summary}</div>
|
||
<div class="wl-meta">{e.minutes} мин · {formatDate(e.createdAt)}</div>
|
||
</div>
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
|
||
{:else if activeTab === 'activity'}
|
||
<div class="empty-state"><p>Активность появится позже</p></div>
|
||
{/if}
|
||
</div>
|
||
|
||
{:else if selectedSection === 'today' && todayDashboard}
|
||
<div class="today-dashboard">
|
||
<div class="today-header">
|
||
<h2>Сегодня</h2>
|
||
<span class="today-date">{todayDashboard.date}</span>
|
||
</div>
|
||
{#if todayDashboard.summary}
|
||
<div class="today-summary">
|
||
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, 'дело', 'дела', 'дел')}</span>{/if}
|
||
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, 'заметка', 'заметки', 'заметок')}</span>{/if}
|
||
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, 'файл', 'файла', 'файлов')}</span>{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if todayDashboard.groups && todayDashboard.groups.length > 0}
|
||
{#each todayDashboard.groups as group}
|
||
<div class="today-case">
|
||
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||
<span class="today-case-title">{group.nodeTitle}</span>
|
||
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
|
||
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
|
||
</div>
|
||
{#if group.events && group.events.length > 0}
|
||
<div class="today-events">
|
||
{#each group.events as ev}
|
||
<div class="today-event">
|
||
<span class="today-event-icon">{eventIcon(ev.eventType)}</span>
|
||
<span class="today-event-title">{ev.title}</span>
|
||
<span class="today-event-type">{eventLabel(ev.eventType)}</span>
|
||
<span class="today-event-time">{formatTime(ev.createdAt)}</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<div class="today-events-empty">Изменён сегодня, подробная история пока недоступна</div>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
|
||
{#if todayDashboard.events && todayDashboard.events.length > 0}
|
||
<div class="today-timeline">
|
||
<h3>Лента за сегодня</h3>
|
||
{#each todayDashboard.events as ev}
|
||
<div class="timeline-event">
|
||
<span class="timeline-dot"></span>
|
||
<span class="timeline-title">{ev.title}</span>
|
||
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
|
||
<span class="timeline-time">{formatTime(ev.createdAt)}</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
<div class="today-empty">
|
||
<p>Сегодня пока тихо</p>
|
||
<p class="hint">Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.</p>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{:else}
|
||
<div class="welcome">
|
||
<h2>Верстак</h2>
|
||
{#if loading}<p>Загрузка...</p>
|
||
{:else if sections.length > 0}
|
||
<p>Выберите раздел в боковой панели.</p>
|
||
<p class="hint">Или создайте новое дело кнопкой «+».</p>
|
||
{:else if error}<p class="error-text">Ошибка: {error}</p>{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox'}
|
||
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
||
{/if}
|
||
|
||
{#if showCreateNode}
|
||
<div class="modal-overlay" on:click|self={cancelCreateNode}>
|
||
<div class="modal">
|
||
<h3>Новое дело</h3>
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input type="text" placeholder="Название дела" bind:value={newNodeTitle}
|
||
on:keydown={(e) => e.key === 'Enter' && submitCreateNode()} autofocus />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Раздел</label>
|
||
<select bind:value={newNodeSection}>
|
||
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox') as s}
|
||
<option value={s.id}>{s.label}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
|
||
<button class="btn" on:click={cancelCreateNode}>Отмена</button>
|
||
</div>
|
||
</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 showRename}
|
||
<div class="modal-overlay" on:click|self={cancelRename}>
|
||
<div class="modal">
|
||
<h3>Переименовать</h3>
|
||
<div class="form-group">
|
||
<label>Новое имя</label>
|
||
<input type="text" bind:value={renameValue}
|
||
on:keydown={onRenameKeydown} />
|
||
</div>
|
||
{#if renameError}
|
||
<div class="rename-error">{renameError}</div>
|
||
{/if}
|
||
<div class="modal-actions">
|
||
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
|
||
<button class="btn" on:click={cancelRename}>Отмена</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if showConfirm}
|
||
<ConfirmModal
|
||
title={confirmTitle}
|
||
message={confirmMessage}
|
||
confirmText={confirmText}
|
||
danger={confirmDanger}
|
||
on:confirm={handleConfirm}
|
||
on:cancel={handleCancel}
|
||
/>
|
||
{/if}
|
||
</main>
|
||
</div>
|
||
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
.app { display: flex; width: 100vw; height: 100vh; overflow: hidden; background: #13131f; color: #e4e4ef; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; }
|
||
|
||
/* Sidebar */
|
||
.sidebar { width: 260px; min-width: 200px; height: 100vh; display: flex; flex-direction: column; background: #1a1a28; border-right: 1px solid #2a2a3c; flex-shrink: 0; overflow: hidden; }
|
||
.sidebar-brand { padding: 16px 20px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
|
||
.logo { font-size: 20px; line-height: 1; }
|
||
.brand-name { font-size: 16px; font-weight: 600; }
|
||
.sidebar-nav { flex: 1; overflow-y: auto; padding: 12px 0; }
|
||
.nav-group { margin-bottom: 16px; }
|
||
.nav-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; padding: 4px 20px; margin-bottom: 4px; }
|
||
.nav-item { display: block; width: 100%; padding: 8px 20px; border: none; background: none; color: #ccc; font-size: 13px; text-align: left; cursor: pointer; border-radius: 0; font-family: inherit; }
|
||
.nav-item:hover { background: #222233; }
|
||
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
|
||
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
|
||
.sidebar-footer { padding: 12px 20px; border-top: 1px solid #2a2a3c; flex-shrink: 0; }
|
||
.version { font-size: 11px; color: #555; }
|
||
|
||
/* Main */
|
||
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; }
|
||
.header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; flex-shrink: 0; min-height: 48px; }
|
||
.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; 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; }
|
||
.tab { padding: 10px 16px; border: none; background: none; color: #888; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; font-family: inherit; }
|
||
.tab:hover { color: #ccc; }
|
||
.tab.active { color: #e4e4ef; border-bottom-color: #6366f1; }
|
||
|
||
/* Tab content */
|
||
.tab-content { flex: 1; overflow-y: auto; }
|
||
|
||
/* Note editor */
|
||
.note-editor { flex: 1; display: flex; flex-direction: column; height: 100%; }
|
||
.note-editor-header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||
.note-title { font-size: 16px; font-weight: 500; }
|
||
.dirty-mark { color: #f59e0b; font-size: 10px; }
|
||
.note-editor-actions { margin-left: auto; display: flex; gap: 8px; }
|
||
.note-textarea { flex: 1; width: 100%; border: none; outline: none; background: #13131f; color: #e4e4ef; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; padding: 24px; resize: none; }
|
||
|
||
/* Overview */
|
||
.overview { padding: 24px; }
|
||
.overview h2 { font-size: 24px; margin-bottom: 16px; }
|
||
.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||
.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; 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; }
|
||
.recent-section h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 8px; }
|
||
.recent-note { padding: 8px 12px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; }
|
||
.recent-note:hover { background: #1a1a28; }
|
||
.recent-date { font-size: 11px; color: #555; }
|
||
.recent-entry { padding: 6px 0; font-size: 13px; color: #888; border-bottom: 1px solid #1a1a28; }
|
||
|
||
/* Notes tab */
|
||
.notes-tab { padding: 24px; }
|
||
.tab-toolbar { margin-bottom: 16px; }
|
||
.create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
|
||
.create-form input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 8px; }
|
||
.create-form input:focus { outline: none; border-color: #6366f1; }
|
||
.form-actions { display: flex; gap: 8px; }
|
||
.notes-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||
.note-card { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 16px; cursor: pointer; }
|
||
.note-card:hover { border-color: #3a3a5c; }
|
||
.note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
|
||
.note-card-date { font-size: 11px; color: #555; }
|
||
|
||
/* Worklog tab */
|
||
.worklog-tab { padding: 24px; }
|
||
.worklog-form { display: flex; gap: 8px; margin-bottom: 24px; align-items: center; }
|
||
.worklog-form input { padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; }
|
||
.worklog-form input:focus { outline: none; border-color: #6366f1; }
|
||
.worklog-form input[type="text"] { flex: 1; }
|
||
.worklog-form input[type="number"] { width: 70px; }
|
||
.worklog-entry { padding: 12px 0; border-bottom: 1px solid #1a1a28; }
|
||
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
||
|
||
/* Actions */
|
||
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
||
|
||
/* Empty states */
|
||
.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 */
|
||
.welcome { padding: 48px 24px; text-align: center; }
|
||
.welcome h2 { font-size: 32px; font-weight: 300; color: #8888a4; margin-bottom: 16px; }
|
||
.welcome p { color: #666; font-size: 14px; }
|
||
.error-text { color: #ff8888; }
|
||
|
||
/* FAB */
|
||
.fab { position: fixed; bottom: 24px; right: 24px; width: 56px; height: 56px; border-radius: 50%; background: #6366f1; color: #fff; font-size: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); }
|
||
.fab:hover { background: #4f46e5; }
|
||
|
||
/* Modal */
|
||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||
.modal { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 12px; padding: 24px; width: 400px; max-width: 90vw; }
|
||
.modal h3 { font-size: 18px; margin-bottom: 16px; }
|
||
.form-group { margin-bottom: 12px; }
|
||
.form-group label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; }
|
||
.form-group input, .form-group select { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; }
|
||
.form-group select { appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 32px; }
|
||
.form-group input:focus, .form-group select:focus { outline: none; border-color: #6366f1; }
|
||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
||
|
||
/* Buttons */
|
||
.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: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; }
|
||
|
||
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
|
||
|
||
/* Today Dashboard */
|
||
.today-dashboard { padding: 24px; overflow-y: auto; flex: 1; }
|
||
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
||
.today-header h2 { font-size: 24px; }
|
||
.today-date { font-size: 13px; color: #666; }
|
||
.today-summary { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||
.summary-chip { font-size: 12px; color: #b0b0c0; background: #1a1a28; border: 1px solid #2a2a3c; padding: 4px 12px; border-radius: 16px; }
|
||
.today-case { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
|
||
.today-case-header { padding: 12px 16px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #2a2a3c; cursor: pointer; }
|
||
.today-case-header:hover { background: #1e1e30; }
|
||
.today-case-title { font-weight: 500; }
|
||
.today-case-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
||
.today-case-time { font-size: 11px; color: #555; margin-left: auto; }
|
||
.today-events { padding: 8px 16px; }
|
||
.today-event { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; }
|
||
.today-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; }
|
||
.today-event-title { flex: 1; }
|
||
.today-event-type { font-size: 11px; color: #666; }
|
||
.today-event-time { font-size: 11px; color: #555; margin-left: auto; }
|
||
.today-events-empty { padding: 8px 16px; font-size: 13px; color: #666; font-style: italic; }
|
||
.today-empty { padding: 48px 24px; text-align: center; }
|
||
.today-empty p { color: #666; font-size: 14px; margin: 0; }
|
||
.today-empty .hint { font-size: 12px; color: #555; margin-top: 8px; }
|
||
.today-timeline { margin-top: 24px; }
|
||
.today-timeline h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 12px; }
|
||
.timeline-event { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; color: #b0b0c0; border-left: 2px solid #2a2a3c; padding-left: 16px; margin-left: 4px; }
|
||
.timeline-dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; margin-left: -19px; flex-shrink: 0; }
|
||
.timeline-title { flex: 1; }
|
||
.timeline-type { font-size: 11px; color: #666; }
|
||
.timeline-time { font-size: 11px; color: #555; }
|
||
</style>
|