verstak/frontend/src/App.svelte

1434 lines
56 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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">&#9874;</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>