1794 lines
75 KiB
Svelte
1794 lines
75 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 activityFeed = []
|
||
let activityOffset = 0
|
||
let activityHasMore = true
|
||
let activityLoading = false
|
||
let caseActivity = []
|
||
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 newNodeTemplate = ''
|
||
let templates = []
|
||
let showCreateNote = false
|
||
let newNoteTitle = ''
|
||
let showCreateAction = false
|
||
let newActionTitle = ''
|
||
let newActionKind = 'open_url'
|
||
let newActionData = ''
|
||
let actionKinds = [
|
||
{ id: 'open_url', label: 'Открыть URL' },
|
||
{ id: 'open_file', label: 'Открыть файл' },
|
||
{ id: 'open_folder', label: 'Открыть папку' },
|
||
{ id: 'run_command', label: 'Запустить команду' },
|
||
{ id: 'run_script', label: 'Запустить скрипт' },
|
||
{ id: 'open_terminal', label: 'Открыть терминал' },
|
||
{ id: 'launch_app', label: 'Запустить приложение' },
|
||
]
|
||
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 = ''
|
||
|
||
// ===== Sync state =====
|
||
let showSettings = false
|
||
let syncStatus = null
|
||
let syncLoading = false
|
||
let syncServerUrl = ''
|
||
let syncUsername = ''
|
||
let syncPassword = ''
|
||
let syncInterval = 0
|
||
let syncResult = ''
|
||
|
||
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: 'activity', 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
|
||
loadSyncStatus()
|
||
})
|
||
|
||
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
|
||
activityFeed = []
|
||
activityOffset = 0
|
||
activityHasMore = true
|
||
nodes = []
|
||
try {
|
||
if (id === 'today') {
|
||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||
} else if (id === 'activity') {
|
||
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
|
||
activityOffset = activityFeed.length
|
||
activityHasMore = activityFeed.length === 50
|
||
} else {
|
||
nodes = await wailsCall('ListNodesBySection', id) || []
|
||
}
|
||
} catch (e) {
|
||
error = String(e)
|
||
nodes = []
|
||
todayDashboard = { cases: [] }
|
||
activityFeed = []
|
||
}
|
||
}
|
||
|
||
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 = ''
|
||
caseActivity = []
|
||
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) {}
|
||
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } 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'
|
||
newNodeTemplate = ''
|
||
wailsCall('ListTemplates').then(t => { templates = t || [] }).catch(() => { templates = [] })
|
||
}
|
||
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
|
||
async function submitCreateNode() {
|
||
if (!newNodeTitle.trim()) return
|
||
try {
|
||
let node
|
||
if (newNodeTemplate) {
|
||
node = await wailsCall('FromTemplate', '', 'case', newNodeTitle.trim(), newNodeSection, newNodeTemplate)
|
||
} else {
|
||
node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
|
||
}
|
||
showCreateNode = false
|
||
newNodeTitle = ''
|
||
newNodeTemplate = ''
|
||
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
|
||
}
|
||
// ===== Actions =====
|
||
function openCreateAction() {
|
||
showCreateAction = true
|
||
newActionTitle = ''
|
||
newActionKind = 'open_url'
|
||
newActionData = ''
|
||
}
|
||
function cancelCreateAction() { showCreateAction = false; newActionTitle = ''; newActionData = '' }
|
||
async function submitCreateAction() {
|
||
if (!newActionTitle.trim() || !newActionData.trim() || !selectedNode) return
|
||
try {
|
||
const action = await wailsCall('CreateAction', selectedNode.id, newActionKind, newActionTitle.trim(), newActionData.trim())
|
||
if (action && action.id) {
|
||
actions = [...actions, action]
|
||
}
|
||
showCreateAction = false
|
||
newActionTitle = ''
|
||
newActionData = ''
|
||
} catch (e) { error = String(e) }
|
||
}
|
||
async function deleteAction(id) {
|
||
try {
|
||
await wailsCall('DeleteAction', id)
|
||
actions = actions.filter(a => a.id !== id)
|
||
} catch (e) { error = String(e) }
|
||
}
|
||
function actionKindLabel(kind) {
|
||
const k = actionKinds.find(k => k.id === kind)
|
||
return k ? k.label : kind
|
||
}
|
||
|
||
async function openNodeById(id) {
|
||
try {
|
||
const node = await wailsCall('GetNodeDetail', id)
|
||
if (node) selectNode(node)
|
||
} catch (e) {
|
||
error = String(e)
|
||
}
|
||
}
|
||
|
||
// ===== Sync =====
|
||
async function loadSyncStatus() {
|
||
try {
|
||
syncStatus = await wailsCall('SyncStatus')
|
||
} catch (e) {
|
||
syncStatus = { configured: false, serverUrl: '', deviceId: '', unpushedOps: 0, lastSyncAt: '', syncInterval: 0 }
|
||
}
|
||
}
|
||
|
||
function openSettings() {
|
||
showSettings = true
|
||
syncServerUrl = syncStatus?.serverUrl || ''
|
||
syncUsername = ''
|
||
syncPassword = ''
|
||
syncInterval = syncStatus?.syncInterval || 0
|
||
syncResult = ''
|
||
}
|
||
|
||
function closeSettings() {
|
||
showSettings = false
|
||
syncResult = ''
|
||
}
|
||
|
||
async function saveSyncConfig() {
|
||
syncLoading = true
|
||
syncResult = ''
|
||
try {
|
||
await wailsCall('SyncConfigure', syncServerUrl, syncUsername, syncPassword)
|
||
if (syncInterval > 0) {
|
||
await wailsCall('SyncSetInterval', syncInterval)
|
||
}
|
||
syncResult = 'ok'
|
||
await loadSyncStatus()
|
||
} catch (e) {
|
||
syncResult = 'err: ' + String(e)
|
||
}
|
||
syncLoading = false
|
||
}
|
||
|
||
async function testConnection() {
|
||
syncLoading = true
|
||
syncResult = ''
|
||
try {
|
||
await wailsCall('SyncTestConnection', syncServerUrl, syncUsername, syncPassword)
|
||
syncResult = 'connection ok'
|
||
} catch (e) {
|
||
syncResult = 'connection failed: ' + String(e)
|
||
}
|
||
syncLoading = false
|
||
}
|
||
|
||
async function runSyncNow() {
|
||
syncLoading = true
|
||
syncResult = ''
|
||
try {
|
||
const r = await wailsCall('SyncNow')
|
||
syncResult = 'pushed ' + r.pushed + ', pulled ' + r.pulled + ' (rev ' + r.serverRevision + ')'
|
||
await loadSyncStatus()
|
||
} catch (e) {
|
||
syncResult = 'err: ' + String(e)
|
||
}
|
||
syncLoading = false
|
||
}
|
||
</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' && selectedSection !== 'activity'}
|
||
<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">
|
||
<button class="sidebar-sync-btn" on:click={openSettings} title="Настройки синхронизации">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||
<span class="sync-dot" class:active={syncStatus?.configured}></span>
|
||
<span class="sidebar-sync-label">Синхронизация</span>
|
||
</button>
|
||
<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>
|
||
<div class="header-right">
|
||
{#if syncStatus?.configured}
|
||
<button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title="Синхронизировать">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||
{#if syncStatus.unpushedOps > 0}
|
||
<span class="sync-badge">{syncStatus.unpushedOps}</span>
|
||
{/if}
|
||
</button>
|
||
{/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" on:click={openCreateAction}>
|
||
<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'}
|
||
<div class="actions-tab">
|
||
<div class="tab-toolbar">
|
||
<button class="btn btn-primary" on:click={openCreateAction}>+ Добавить действие</button>
|
||
</div>
|
||
{#if actions.length === 0}
|
||
<div class="empty-state"><p>Действий пока нет</p></div>
|
||
{:else}
|
||
{#each actions as action}
|
||
<div class="action-card">
|
||
<div class="action-info">
|
||
<span class="action-title">{action.title}</span>
|
||
<span class="action-type">{actionKindLabel(action.type)}</span>
|
||
<span class="action-data">{action.data}</span>
|
||
</div>
|
||
<div class="action-btns">
|
||
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
|
||
<button class="btn btn-sm btn-danger" on:click={() => deleteAction(action.id)}>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
|
||
{: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="activity-tab">
|
||
{#if caseActivity.length === 0}
|
||
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||
{:else}
|
||
<div class="activity-events">
|
||
{#each caseActivity as ev}
|
||
<div class="activity-event">
|
||
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
|
||
<span class="activity-event-title">{ev.title}</span>
|
||
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
|
||
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||
<span class="activity-event-time">{formatTime(ev.createdAt)}</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</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>
|
||
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, 'событие', 'события', 'событий')}</span>{/if}
|
||
<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" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||
<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>
|
||
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||
<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" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||
<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 if selectedSection === 'activity'}
|
||
<div class="activity-feed">
|
||
<div class="activity-feed-header">
|
||
<h2>Активность</h2>
|
||
</div>
|
||
{#if activityFeed.length === 0}
|
||
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||
{:else}
|
||
<div class="activity-feed-events">
|
||
{#each activityFeed as ev}
|
||
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||
<span class="activity-feed-icon">{eventIcon(ev.eventType)}</span>
|
||
<div class="activity-feed-body">
|
||
<span class="activity-feed-title">{ev.title}</span>
|
||
<div class="activity-feed-meta">
|
||
<span class="activity-feed-type">{eventLabel(ev.eventType)}</span>
|
||
{#if ev.targetType}<span class="activity-feed-target">{ev.targetType}</span>{/if}
|
||
<span class="activity-feed-time">{formatDate(ev.createdAt)} {formatTime(ev.createdAt)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</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' && selectedSection !== 'activity'}
|
||
<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' && s.id !== 'activity') as s}
|
||
<option value={s.id}>{s.label}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
{#if templates.length > 0}
|
||
<div class="form-group">
|
||
<label>Шаблон (опционально)</label>
|
||
<select bind:value={newNodeTemplate}>
|
||
<option value="">Без шаблона</option>
|
||
{#each templates as t}
|
||
<option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
{/if}
|
||
<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 showCreateAction}
|
||
<div class="modal-overlay" on:click|self={cancelCreateAction}>
|
||
<div class="modal">
|
||
<h3>Новое действие</h3>
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input type="text" placeholder="Например: Открыть сайт" bind:value={newActionTitle}
|
||
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Тип</label>
|
||
<select bind:value={newActionKind}>
|
||
{#each actionKinds as k}
|
||
<option value={k.id}>{k.label}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>{newActionKind === 'open_url' ? 'URL' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? 'Путь' : 'Команда'}</label>
|
||
<input type="text" placeholder={newActionKind === 'open_url' ? 'https://example.com' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? '/path/to/file' : 'команда'}
|
||
bind:value={newActionData}
|
||
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-primary" on:click={submitCreateAction}>Создать</button>
|
||
<button class="btn" on:click={cancelCreateAction}>Отмена</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}
|
||
|
||
{#if showSettings}
|
||
<div class="modal-overlay" on:click|self={closeSettings}>
|
||
<div class="modal modal-sync">
|
||
<h3>Настройки синхронизации</h3>
|
||
{#if syncStatus}
|
||
<div class="sync-status">
|
||
<div class="sync-row"><span class="sync-label">Статус</span><span class="sync-value">{syncStatus.configured ? 'Включена' : 'Отключена'}</span></div>
|
||
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl || '—'}</span></div>
|
||
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value mono">{syncStatus.deviceId || '—'}</span></div>
|
||
<div class="sync-row"><span class="sync-label">Неотправлено</span><span class="sync-value">{syncStatus.unpushedOps}</span></div>
|
||
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt || '—'}</span></div>
|
||
</div>
|
||
{/if}
|
||
<div class="form-group">
|
||
<label>URL сервера</label>
|
||
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Логин</label>
|
||
<input type="text" placeholder="username" bind:value={syncUsername} />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Пароль</label>
|
||
<input type="password" placeholder="password" bind:value={syncPassword} />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Автосинхронизация (мин)</label>
|
||
<input type="number" placeholder="0 = отключено" bind:value={syncInterval} min="0" />
|
||
</div>
|
||
{#if syncResult}
|
||
<div class="sync-result">{syncResult}</div>
|
||
{/if}
|
||
<div class="modal-actions">
|
||
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button>
|
||
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button>
|
||
<button class="btn" on:click={runSyncNow} disabled={syncLoading || !syncStatus?.configured}>Синхронизировать</button>
|
||
<button class="btn" on:click={closeSettings}>Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/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: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
|
||
.version { font-size: 11px; color: #555; text-align: center; }
|
||
|
||
/* 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; }
|
||
.header-left { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||
.header-right { display: flex; align-items: center; gap: 8px; }
|
||
.header-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: #b0b0c0; font-family: inherit; font-size: 13px; position: relative; }
|
||
.header-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
|
||
.header-sync-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.sync-badge { background: #6366f1; color: #fff; font-size: 10px; border-radius: 50%; width: 16px; height: 16px; display: inline-flex; align-items: center; justify-content: center; position: absolute; top: -6px; right: -6px; }
|
||
.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 */
|
||
.actions-tab { padding: 24px; }
|
||
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||
.action-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
||
.action-title { font-weight: 500; }
|
||
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
|
||
.action-data { font-size: 11px; color: #555; font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
|
||
.action-btns { display: flex; gap: 4px; flex-shrink: 0; }
|
||
.action-btns .btn-danger { color: #ff6b6b; border-color: #4a2222; padding: 4px 8px; }
|
||
.action-btns .btn-danger:hover { background: #3a2222; }
|
||
|
||
/* 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-count { font-size: 11px; color: #6366f1; margin-left: 4px; }
|
||
.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; cursor: pointer; }
|
||
.today-event:hover { color: #e4e4ef; }
|
||
.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; cursor: pointer; }
|
||
.timeline-event:hover { color: #e4e4ef; }
|
||
.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; }
|
||
|
||
/* Activity tab (per-case) */
|
||
.activity-tab { padding: 24px; }
|
||
.activity-events { display: flex; flex-direction: column; gap: 2px; }
|
||
.activity-event { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 13px; color: #b0b0c0; cursor: pointer; }
|
||
.activity-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||
.activity-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
|
||
.activity-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.activity-event-type { font-size: 11px; color: #666; flex-shrink: 0; }
|
||
.activity-event-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
|
||
.activity-event-time { font-size: 11px; color: #555; margin-left: 8px; flex-shrink: 0; }
|
||
|
||
/* Activity feed (global section) */
|
||
.activity-feed { padding: 24px; overflow-y: auto; flex: 1; }
|
||
.activity-feed-header { margin-bottom: 20px; }
|
||
.activity-feed-header h2 { font-size: 24px; }
|
||
.activity-feed-events { display: flex; flex-direction: column; gap: 2px; }
|
||
.activity-feed-event { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
|
||
.activity-feed-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||
.activity-feed-icon { width: 20px; text-align: center; color: #6366f1; font-size: 14px; flex-shrink: 0; margin-top: 1px; }
|
||
.activity-feed-body { flex: 1; min-width: 0; }
|
||
.activity-feed-title { font-size: 14px; color: #e4e4ef; }
|
||
.activity-feed-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
||
.activity-feed-type { font-size: 11px; color: #666; }
|
||
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
|
||
.activity-feed-time { font-size: 11px; color: #555; }
|
||
|
||
/* Sync */
|
||
.sidebar-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 8px 12px; cursor: pointer; width: 100%; display: flex; align-items: center; gap: 8px; color: #888; font-family: inherit; font-size: 13px; }
|
||
.sidebar-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
|
||
.sidebar-sync-label { flex: 1; text-align: left; }
|
||
.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4a4a; flex-shrink: 0; }
|
||
.sync-dot.active { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.5); }
|
||
.modal-sync { width: 460px; }
|
||
.sync-status { background: #13131f; border-radius: 8px; padding: 12px; margin-bottom: 16px; }
|
||
.sync-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
|
||
.sync-label { color: #666; }
|
||
.sync-value { color: #e4e4ef; }
|
||
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
|
||
</style>
|