Compare commits
No commits in common. "rescue/broken-app-split" and "master" have entirely different histories.
rescue/bro
...
master
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -19,8 +19,8 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-C0D__Sxo.js"></script>
|
<script type="module" crossorigin src="/assets/main-CzfuqGWF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-Bl-yCbt2.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
# Frontend Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Verstak frontend is a Svelte 3 application running inside Wails v2 (Go bridge).
|
|
||||||
The app manages a hierarchical vault of nodes (folders/cases, notes, files, links, actions)
|
|
||||||
with sync capabilities, worklog/journal, and activity tracking.
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
- **UI Framework:** Svelte 3 (plain JS, no TypeScript in components)
|
|
||||||
- **Desktop Bridge:** Wails v2 (`window.go.main.App.*`)
|
|
||||||
- **Bundler:** Vite (via Wails)
|
|
||||||
- **Markdown:** Custom renderer in `lib/markdown/`
|
|
||||||
- **i18n:** Custom lightweight system in `lib/i18n/`
|
|
||||||
- **Styling:** Scoped CSS in Svelte components, dark theme
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/src/
|
|
||||||
├── App.svelte # Root component (being modularised)
|
|
||||||
├── TreeNode.svelte # Tree node for sidebar (inline)
|
|
||||||
├── FileTreeRow.svelte # File row in file tab (inline)
|
|
||||||
├── wailsjs/go/main/App.js # Auto-generated Wails bindings
|
|
||||||
├── lib/
|
|
||||||
│ ├── components/ # Reusable UI components
|
|
||||||
│ │ └── notes/
|
|
||||||
│ │ ├── NoteEditorPanel.svelte
|
|
||||||
│ │ ├── MarkdownEditor.svelte
|
|
||||||
│ │ ├── MarkdownPreview.svelte
|
|
||||||
│ │ ├── InternalLinkPicker.svelte
|
|
||||||
│ │ └── ObjectPickerModal.svelte
|
|
||||||
│ ├── services/ # API/Data access layer
|
|
||||||
│ │ ├── wails.js # Base Wails call helper
|
|
||||||
│ │ ├── notes.js # Notes API
|
|
||||||
│ │ ├── files.js # Files API
|
|
||||||
│ │ ├── search.js # Search API
|
|
||||||
│ │ ├── inbox.js # Inbox API
|
|
||||||
│ │ ├── trash.js # Trash API
|
|
||||||
│ │ ├── sync.js # Sync API
|
|
||||||
│ │ ├── journal.js # Journal/Worklog API
|
|
||||||
│ │ ├── actions.js # Actions API
|
|
||||||
│ │ ├── links.js # Links API
|
|
||||||
│ │ └── activity.js # Activity API
|
|
||||||
│ ├── state/ # State management (planned)
|
|
||||||
│ │ ├── navigation.js # Navigation state
|
|
||||||
│ │ └── uiState.js # UI state
|
|
||||||
│ ├── markdown/ # Markdown processing
|
|
||||||
│ │ ├── markdown.ts
|
|
||||||
│ │ └── internalLinks.ts
|
|
||||||
│ ├── i18n/ # Internationalisation
|
|
||||||
│ │ ├── index.js
|
|
||||||
│ │ └── locales/
|
|
||||||
│ │ ├── en.js
|
|
||||||
│ │ └── ru.js
|
|
||||||
│ ├── util/ # Utilities
|
|
||||||
│ │ ├── keyboardLayout.ts
|
|
||||||
│ │ └── markdown.test.js
|
|
||||||
│ ├── AppHeader.svelte
|
|
||||||
│ ├── GlobalSearch.svelte
|
|
||||||
│ ├── FileBreadcrumbs.svelte
|
|
||||||
│ ├── FileIcon.svelte
|
|
||||||
│ ├── FilePreviewModal.svelte
|
|
||||||
│ ├── ConfirmModal.svelte
|
|
||||||
│ ├── TodayScreen.svelte
|
|
||||||
│ ├── BrowserEvents.svelte
|
|
||||||
│ ├── FirstRun.svelte
|
|
||||||
│ ├── VaultRecovery.svelte
|
|
||||||
│ ├── SyncStatus.svelte
|
|
||||||
│ ├── TemplateIcon.svelte
|
|
||||||
│ ├── CalendarPluginPage.svelte
|
|
||||||
│ ├── SettingsWindow.svelte
|
|
||||||
│ ├── SettingsSidebar.svelte
|
|
||||||
│ ├── SettingsGeneral.svelte
|
|
||||||
│ ├── SettingsSync.svelte
|
|
||||||
│ ├── SettingsPlugins.svelte
|
|
||||||
│ ├── SettingsBrowserBridge.svelte
|
|
||||||
│ ├── SettingsWorkspace.svelte
|
|
||||||
│ ├── SettingsTemplates.svelte
|
|
||||||
│ ├── SettingsFiles.svelte
|
|
||||||
│ ├── SettingsBackup.svelte
|
|
||||||
│ ├── SettingsActivity.svelte
|
|
||||||
│ ├── actionIcons.js
|
|
||||||
│ └── fileUtils.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Wails Bridge
|
|
||||||
|
|
||||||
All backend calls go through `window.go.main.App[method](...)`.
|
|
||||||
The `wailsCall()` helper in `lib/services/wails.js` provides error handling.
|
|
||||||
|
|
||||||
## Planned Components (to extract from App.svelte)
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
- `AppShell.svelte` — root layout wrapper
|
|
||||||
- `Sidebar.svelte` — navigation sidebar
|
|
||||||
- `MainWorkspace.svelte` — main content area
|
|
||||||
|
|
||||||
### Pages/Tab Content
|
|
||||||
- `OverviewTab.svelte` — node overview with meta and quick actions
|
|
||||||
- `NotesTab.svelte` — notes list and creation
|
|
||||||
- `FilesTab.svelte` — file browser with breadcrumbs
|
|
||||||
- `InboxContent.svelte` + `InboxFullScreen.svelte`
|
|
||||||
- `LinksTab.svelte`
|
|
||||||
- `ActionsTab.svelte`
|
|
||||||
- `WorklogTab.svelte`
|
|
||||||
- `ActivityTabContent.svelte`
|
|
||||||
- `TrashContent.svelte`
|
|
||||||
- `JournalScreen.svelte`
|
|
||||||
- `ActivityFeedScreen.svelte`
|
|
||||||
- `WelcomeScreen.svelte`
|
|
||||||
|
|
||||||
### Modals
|
|
||||||
- `CreateNodeModal.svelte`
|
|
||||||
- `WorklogModal.svelte`
|
|
||||||
- `CreateActionModal.svelte`
|
|
||||||
- `ImportModal.svelte`
|
|
||||||
- `RenameModal.svelte`
|
|
||||||
- `AssignInboxModal.svelte`
|
|
||||||
- `EditLinkModal.svelte`
|
|
||||||
- `LinkInsertModal.svelte`
|
|
||||||
- `NoteRenameModal.svelte`
|
|
||||||
- `ContextMenu.svelte`
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
1. User interacts with UI component
|
|
||||||
2. Component calls a service function (e.g., `notesApi.createNote(...)`)
|
|
||||||
3. Service calls `wailsCall('CreateNote', ...)`
|
|
||||||
4. Wails bridge forwards to Go backend
|
|
||||||
5. Go backend returns result → Wails → service → component updates state
|
|
||||||
|
|
||||||
## State Management
|
|
||||||
|
|
||||||
Currently all state lives in App.svelte as local variables.
|
|
||||||
Target: extract into `lib/state/navigation.js` and `lib/state/uiState.js`.
|
|
||||||
|
|
||||||
### Files Flow
|
|
||||||
|
|
||||||
- **Component:** `lib/components/files/FilesTab.svelte` — self-contained file browser
|
|
||||||
- **API services:** `lib/services/files.js`, `lib/services/nodes.js`
|
|
||||||
- **Events emitted:**
|
|
||||||
- `on:openNote` — when a .md file linked to a note is opened
|
|
||||||
- `on:refreshParent` — after file operations that modify the tree
|
|
||||||
- `on:error` — on operation failures
|
|
||||||
- `on:rename` — requests parent to show rename modal
|
|
||||||
- `on:confirm` — requests parent to show confirm dialog
|
|
||||||
- **Public methods:**
|
|
||||||
- `resetToNode(nodeId)` — reset state when selected node changes
|
|
||||||
- `filesHandleKeydown(e)` — keyboard handler for files tab
|
|
||||||
- **.md → note editor flow:** Handled inside FilesTab via `CheckFileAction` Wails call. If action is 'note', emits `openNote`. If 'external', opens in system viewer. Otherwise shows built-in preview.
|
|
||||||
- **File preview:** `FilePreviewModal.svelte` (already existed), invoked by FilesTab
|
|
||||||
- **Import dialog:** Inline in FilesTab template (moved from App.svelte)
|
|
||||||
|
|
||||||
## Build & Verification
|
|
||||||
|
|
||||||
- `npm run build` in `frontend/` directory
|
|
||||||
- `go test ./...` from project root
|
|
||||||
- `bash scripts/build.sh gui` for full GUI binary
|
|
||||||
- Manual smoke testing via Wails dev server
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
# Frontend Change Map
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This document tracks the refactoring of `App.svelte` from a 4794-line monolith
|
|
||||||
into a modular frontend architecture. Each step preserves behaviour exactly.
|
|
||||||
|
|
||||||
## Phase 1: Documentation & Foundation
|
|
||||||
|
|
||||||
- [x] Audit App.svelte (all 4794 lines read and mapped)
|
|
||||||
- [x] Create `docs/frontend-architecture.md`
|
|
||||||
- [x] Create `docs/frontend-change-map.md`
|
|
||||||
|
|
||||||
## Phase 2: API Layer
|
|
||||||
|
|
||||||
Extract all Wails calls into service modules.
|
|
||||||
|
|
||||||
- [x] Create `lib/services/wails.js` — base `wailsCall` helper
|
|
||||||
- [x] Create `lib/services/notes.js` — `listNotes`, `createNote`, `readNote`, `saveNote`, `renameNote`, `deleteNote`
|
|
||||||
- [x] Create `lib/services/files.js` — `loadFolder`, `addFile`, `deleteFile`, etc.
|
|
||||||
- [x] Create `lib/services/search.js` — `searchNodes`, `getNodeDetail`, `searchWorkspace`
|
|
||||||
- [x] Create `lib/services/inbox.js` — inbox capture and management
|
|
||||||
- [x] Create `lib/services/trash.js` — trash operations
|
|
||||||
- [x] Create `lib/services/sync.js` — sync status and trigger
|
|
||||||
- [x] Create `lib/services/journal.js` — worklog CRUD and reports
|
|
||||||
- [x] Create `lib/services/actions.js` — actions CRUD
|
|
||||||
- [x] Create `lib/services/links.js` — links CRUD
|
|
||||||
- [x] Create `lib/services/activity.js` — activity feed
|
|
||||||
- [x] Create `lib/services/nodes.js` — tree, node CRUD, system views
|
|
||||||
- [x] Create `lib/services/suggestions.js` — worklog suggestions
|
|
||||||
- [x] Create `lib/services/today.js` — today dashboard
|
|
||||||
- [x] Create `lib/services/browserEvents.js` — browser extension events
|
|
||||||
- [x] Create `lib/services/search.js` — `searchNodes`
|
|
||||||
- [x] Create `lib/services/inbox.js` — `listInbox`, `captureClipboard`, etc.
|
|
||||||
- [x] Create `lib/services/trash.js` — `loadTrash`, `restore`, `purge`
|
|
||||||
- [x] Create `lib/services/sync.js` — `loadSyncStatus`, `runSync`
|
|
||||||
- [x] Create `lib/services/journal.js` — `loadJournal`, `worklog CRUD`
|
|
||||||
- [x] Create `lib/services/actions.js` — `listActions`, `createAction`, `deleteAction`
|
|
||||||
- [x] Create `lib/services/links.js` — `listLinks`, `updateLink`, `deleteLink`
|
|
||||||
- [x] Create `lib/services/activity.js` — `loadActivityFeed`, `loadCaseActivity`
|
|
||||||
|
|
||||||
## Phase 3: State Extraction
|
|
||||||
|
|
||||||
- [ ] Create `lib/state/navigation.js` — `selectedSection`, `selectedNode`, `activeTab`, `navHistory`
|
|
||||||
- [ ] Create `lib/state/uiState.js` — modals state, confirm state, rename state, drag state
|
|
||||||
|
|
||||||
## Phase 4: Component Extraction — Layout
|
|
||||||
|
|
||||||
- [ ] Extract `Sidebar.svelte` — brand, nav items, workspace tree, footer
|
|
||||||
- [ ] Extract `MainWorkspace.svelte` — content routing
|
|
||||||
- [ ] Create `AppShell.svelte` — root layout wrapper
|
|
||||||
|
|
||||||
## Phase 5: Component Extraction — Tab Content
|
|
||||||
|
|
||||||
- [x] Extract `OverviewTab.svelte`
|
|
||||||
- [x] Extract `NotesTab.svelte`
|
|
||||||
- [x] Extract `FilesTab.svelte`
|
|
||||||
- [ ] Extract `LinksTab.svelte`
|
|
||||||
- [ ] Extract `ActionsTab.svelte`
|
|
||||||
- [ ] Extract `WorklogTab.svelte`
|
|
||||||
- [ ] Extract `ActivityTabContent.svelte`
|
|
||||||
- [ ] Extract `InboxContent.svelte`
|
|
||||||
- [ ] Extract `InboxFullScreen.svelte`
|
|
||||||
- [ ] Extract `TrashContent.svelte`
|
|
||||||
- [ ] Extract `JournalScreen.svelte`
|
|
||||||
- [ ] Extract `ActivityFeedScreen.svelte`
|
|
||||||
- [ ] Extract `WelcomeScreen.svelte`
|
|
||||||
|
|
||||||
## Phase 6: Component Extraction — Modals
|
|
||||||
|
|
||||||
- [ ] Extract `CreateNodeModal.svelte`
|
|
||||||
- [ ] Extract `WorklogModal.svelte`
|
|
||||||
- [ ] Extract `CreateActionModal.svelte`
|
|
||||||
- [ ] Extract `ImportModal.svelte`
|
|
||||||
- [ ] Extract `RenameModal.svelte`
|
|
||||||
- [ ] Extract `AssignInboxModal.svelte`
|
|
||||||
- [ ] Extract `EditLinkModal.svelte`
|
|
||||||
- [ ] Extract `ContextMenu.svelte`
|
|
||||||
|
|
||||||
## Phase 7: Extract Inline Components
|
|
||||||
|
|
||||||
- [ ] Extract `NoteEditorHeader.svelte` (note editor header with rename)
|
|
||||||
- [ ] Extract `ErrorBanner.svelte`
|
|
||||||
- [ ] Extract `CaptureDropOverlay.svelte`
|
|
||||||
- [ ] Extract `SidebarFooter.svelte`
|
|
||||||
|
|
||||||
## Phase 8: Verification
|
|
||||||
|
|
||||||
- [ ] `npm run build` passes
|
|
||||||
- [ ] `go test ./...` passes
|
|
||||||
- [ ] Smoke checklist:
|
|
||||||
1. Sidebar renders with system views
|
|
||||||
2. Workspace tree loads and is expandable
|
|
||||||
3. Selecting a node shows tabs
|
|
||||||
4. Overview tab shows metadata and quick actions
|
|
||||||
5. Notes tab — create, rename, delete notes
|
|
||||||
6. Note editor — edit, preview, save, internal links, external links
|
|
||||||
7. Files tab — browse, add file/folder, navigate breadcrumbs
|
|
||||||
8. File preview — open, close
|
|
||||||
9. Inbox — list, sort, group, assign, delete
|
|
||||||
10. Trash — browse, restore, purge
|
|
||||||
11. Journal — filter, export, worklog CRUD
|
|
||||||
12. Activity feed — load and open events
|
|
||||||
13. Today screen — dashboard, suggestions, browser events
|
|
||||||
14. Settings — open/close, sections
|
|
||||||
15. Context menu on workspace tree
|
|
||||||
16. Create node modal — templates
|
|
||||||
|
|
@ -18,14 +18,6 @@
|
||||||
import { t } from './lib/i18n'
|
import { t } from './lib/i18n'
|
||||||
import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte'
|
import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte'
|
||||||
import InternalLinkPicker from './lib/components/notes/InternalLinkPicker.svelte'
|
import InternalLinkPicker from './lib/components/notes/InternalLinkPicker.svelte'
|
||||||
import ErrorBanner from './lib/components/ErrorBanner.svelte'
|
|
||||||
import CaptureDropOverlay from './lib/components/CaptureDropOverlay.svelte'
|
|
||||||
import OverviewTab from './lib/components/OverviewTab.svelte'
|
|
||||||
import NotesTab from './lib/components/notes/NotesTab.svelte'
|
|
||||||
import FilesTab from './lib/components/files/FilesTab.svelte'
|
|
||||||
|
|
||||||
// Component refs
|
|
||||||
let filesTabRef = null
|
|
||||||
|
|
||||||
// ===== Wails v2 API call helper =====
|
// ===== Wails v2 API call helper =====
|
||||||
function wailsCall(method, ...args) {
|
function wailsCall(method, ...args) {
|
||||||
|
|
@ -84,19 +76,6 @@
|
||||||
let selectedSection = ''
|
let selectedSection = ''
|
||||||
let selectedNode = null
|
let selectedNode = null
|
||||||
let activeTab = 'overview'
|
let activeTab = 'overview'
|
||||||
// Trash preview state (kept in App.svelte because trash lives outside FilesTab)
|
|
||||||
let previewItem = null
|
|
||||||
let previewContent = ''
|
|
||||||
let previewLoading = false
|
|
||||||
let previewError = ''
|
|
||||||
// Capture/drag-drop overlay state (kept in App.svelte; used by inbox capture flow)
|
|
||||||
let captureDropActive = false
|
|
||||||
let captureDropLabel = ''
|
|
||||||
let captureDragDepth = 0
|
|
||||||
let lastCaptureDragOverAt = 0
|
|
||||||
let captureDragResetTimer = null
|
|
||||||
let dropRootValid = false
|
|
||||||
let inboxDropValid = false
|
|
||||||
let notes = []
|
let notes = []
|
||||||
let noteEditor = null
|
let noteEditor = null
|
||||||
let noteEditorPanel = undefined; // bind:this ref for NoteEditorPanel
|
let noteEditorPanel = undefined; // bind:this ref for NoteEditorPanel
|
||||||
|
|
@ -179,6 +158,33 @@
|
||||||
{ id: 'launch_app', label: t('action.launchApp') },
|
{ id: 'launch_app', label: t('action.launchApp') },
|
||||||
]
|
]
|
||||||
let loading = true
|
let loading = true
|
||||||
|
let importing = false
|
||||||
|
let importSummary = null
|
||||||
|
let showImportDialog = false
|
||||||
|
let pendingImportPath = ''
|
||||||
|
let pendingImportParent = ''
|
||||||
|
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 dropRootValid = false
|
||||||
|
let inboxDropValid = false
|
||||||
|
let captureDropActive = false
|
||||||
|
let captureDropLabel = ''
|
||||||
|
let captureDragDepth = 0
|
||||||
|
let lastCaptureDragOverAt = 0
|
||||||
|
let captureDragResetTimer = null
|
||||||
|
|
||||||
let showConfirm = false
|
let showConfirm = false
|
||||||
let confirmTitle = ''
|
let confirmTitle = ''
|
||||||
|
|
@ -296,10 +302,12 @@
|
||||||
function closeTopModalForBack() {
|
function closeTopModalForBack() {
|
||||||
if (showConfirm) { closeConfirm(); return true }
|
if (showConfirm) { closeConfirm(); return true }
|
||||||
if (showSettings) { closeSettings(); return true }
|
if (showSettings) { closeSettings(); return true }
|
||||||
|
if (previewItem) { closePreview(); return true }
|
||||||
if (assignInboxItem) { closeAssignInbox(); return true }
|
if (assignInboxItem) { closeAssignInbox(); return true }
|
||||||
if (editingLink) { closeEditLink(); return true }
|
if (editingLink) { closeEditLink(); return true }
|
||||||
if (showRename) { showRename = false; return true }
|
if (showRename) { showRename = false; return true }
|
||||||
if (showWorklogModal) { closeWorklogModal(); return true }
|
if (showWorklogModal) { closeWorklogModal(); return true }
|
||||||
|
if (showImportDialog) { cancelImport(); return true }
|
||||||
if (showCreateAction) { cancelCreateAction(); return true }
|
if (showCreateAction) { cancelCreateAction(); return true }
|
||||||
if (showCreateNote) { cancelCreateNote(); return true }
|
if (showCreateNote) { cancelCreateNote(); return true }
|
||||||
if (showCreateNode) { cancelCreateNode(); return true }
|
if (showCreateNode) { cancelCreateNode(); return true }
|
||||||
|
|
@ -318,6 +326,9 @@
|
||||||
}
|
}
|
||||||
await selectNode(node)
|
await selectNode(node)
|
||||||
activeTab = snapshot.tab || 'overview'
|
activeTab = snapshot.tab || 'overview'
|
||||||
|
if (activeTab === 'files' && fileItems.length === 0) {
|
||||||
|
await loadFolder(node.id)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
} else if (snapshot.section) {
|
} else if (snapshot.section) {
|
||||||
if (!systemViews.some(view => view.id === snapshot.section)) {
|
if (!systemViews.some(view => view.id === snapshot.section)) {
|
||||||
|
|
@ -362,6 +373,7 @@
|
||||||
if (activeTab === tabId) return
|
if (activeTab === tabId) return
|
||||||
rememberNavigation()
|
rememberNavigation()
|
||||||
activeTab = tabId
|
activeTab = tabId
|
||||||
|
if (tabId === 'files' && selectedNode && fileItems.length === 0 && !currentFolderId) loadFolder(selectedNode.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Lifecycle =====
|
// ===== Lifecycle =====
|
||||||
|
|
@ -509,13 +521,19 @@
|
||||||
actions = []
|
actions = []
|
||||||
worklog = []
|
worklog = []
|
||||||
suggestions = []
|
suggestions = []
|
||||||
|
fileItems = []
|
||||||
|
folderStack = []
|
||||||
|
currentFolderId = null
|
||||||
|
previewItem = null
|
||||||
|
previewContent = ''
|
||||||
|
selectedIds = []
|
||||||
|
dragIds = []
|
||||||
resetTrashBrowser()
|
resetTrashBrowser()
|
||||||
noteEditor = null
|
noteEditor = null
|
||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
showCreateNote = false
|
showCreateNote = false
|
||||||
error = ''
|
error = ''
|
||||||
caseActivity = []
|
caseActivity = []
|
||||||
if (filesTabRef) filesTabRef.resetToNode(node.id)
|
|
||||||
await loadTabData(node.id)
|
await loadTabData(node.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -533,6 +551,291 @@
|
||||||
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } 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
|
||||||
|
rememberNavigation()
|
||||||
|
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
|
||||||
|
expanded = { ...expanded, [folderId]: true }
|
||||||
|
const children = await wailsCall('ListWorkspaceChildren', folderId) || []
|
||||||
|
setNodeChildren(workspaceTree, folderId, children)
|
||||||
|
workspaceTree = [...workspaceTree]
|
||||||
|
await loadFolder(folderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateBack() {
|
||||||
|
rememberNavigation()
|
||||||
|
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) {
|
||||||
|
// For .md files: check if linked to a note, open note editor instead of preview modal
|
||||||
|
if (item && item.fileId && isMarkdownFile(item)) {
|
||||||
|
try {
|
||||||
|
const action = await wailsCall('CheckFileAction', item.fileId)
|
||||||
|
if (action.action === 'note') {
|
||||||
|
await openNote({ id: action.noteId, title: action.noteTitle })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.action === 'external') {
|
||||||
|
await wailsCall('OpenFile', item.fileId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 'preview' → fall through to normal preview
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('CheckFileAction failed, falling back to preview:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(t('file.namePrompt'))
|
||||||
|
if (!name || !name.trim()) return
|
||||||
|
try {
|
||||||
|
const parentId = currentFolderId || selectedNode.id
|
||||||
|
await wailsCall('CreateEmptyFile', parentId, name.trim())
|
||||||
|
await loadFolder(parentId)
|
||||||
|
await refreshParentNode(parentId)
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function duplicateItem(id) {
|
||||||
|
try {
|
||||||
|
await wailsCall('DuplicateNode', id)
|
||||||
|
const parentId = currentFolderId || selectedNode.id
|
||||||
|
await loadFolder(parentId)
|
||||||
|
await refreshParentNode(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 item = fileItems.find(x => x.id === ids[0])
|
||||||
|
let label
|
||||||
|
if (ids.length === 1 && item?.type === 'folder') {
|
||||||
|
label = t('delete.folder')
|
||||||
|
} else if (ids.length === 1) {
|
||||||
|
label = t('delete.file')
|
||||||
|
} else {
|
||||||
|
label = t('delete.files', { count: ids.length })
|
||||||
|
}
|
||||||
|
openConfirm({
|
||||||
|
title: t('delete.confirmTitle'),
|
||||||
|
message: t('delete.confirmMessage') + ' ' + label + '?',
|
||||||
|
confirmText: t('common.delete'),
|
||||||
|
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) {
|
||||||
|
e.stopPropagation()
|
||||||
|
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.stopPropagation()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(e, folderId) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
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 =====
|
// ===== Keyboard =====
|
||||||
|
|
||||||
function isEditableTarget(target) {
|
function isEditableTarget(target) {
|
||||||
|
|
@ -556,9 +859,47 @@
|
||||||
|
|
||||||
if (activeTab !== 'files') return
|
if (activeTab !== 'files') return
|
||||||
|
|
||||||
if (filesTabRef) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
filesTabRef.filesHandleKeydown(e)
|
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
|
||||||
return
|
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') {
|
||||||
|
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||||||
|
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1638,7 +1979,80 @@
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Drag-and-drop =====
|
// ===== Files =====
|
||||||
|
async function addFile() {
|
||||||
|
const path = await wailsCall('PickFile')
|
||||||
|
if (!path) return
|
||||||
|
const parentId = currentFolderId || selectedNode.id
|
||||||
|
await startImport(parentId, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFolder() {
|
||||||
|
const path = await wailsCall('PickDirectory')
|
||||||
|
if (!path) return
|
||||||
|
const parentId = currentFolderId || selectedNode.id
|
||||||
|
await startImport(parentId, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startImport(parentID, sourcePath) {
|
||||||
|
importing = true
|
||||||
|
try {
|
||||||
|
const summary = await wailsCall('PreviewImport', sourcePath)
|
||||||
|
importSummary = summary
|
||||||
|
pendingImportPath = sourcePath
|
||||||
|
pendingImportParent = parentID
|
||||||
|
showImportDialog = true
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
importing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmImport(mode) {
|
||||||
|
try {
|
||||||
|
const parentId = pendingImportParent || selectedNode.id
|
||||||
|
const result = mode === 'copy'
|
||||||
|
? await wailsCall('AddPathCopy', parentId, pendingImportPath)
|
||||||
|
: await wailsCall('AddPathLink', parentId, pendingImportPath)
|
||||||
|
showImportDialog = false
|
||||||
|
importSummary = null
|
||||||
|
folderStack = []
|
||||||
|
currentFolderId = null
|
||||||
|
await Promise.all([
|
||||||
|
loadTabData(parentId),
|
||||||
|
loadFolder(parentId),
|
||||||
|
refreshParentNode(parentId),
|
||||||
|
])
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelImport() {
|
||||||
|
showImportDialog = false
|
||||||
|
importSummary = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile({ id, type }) {
|
||||||
|
const label = type === 'folder' ? t('delete.folder') : t('delete.file')
|
||||||
|
openConfirm({
|
||||||
|
title: t('delete.confirmTitle'),
|
||||||
|
message: t('delete.confirmMessage') + ' ' + label + '?',
|
||||||
|
confirmText: t('common.delete'),
|
||||||
|
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) {
|
async function openSelectedFile(fileID) {
|
||||||
try {
|
try {
|
||||||
await wailsCall('OpenFile', fileID)
|
await wailsCall('OpenFile', fileID)
|
||||||
|
|
@ -2431,7 +2845,11 @@
|
||||||
<VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} />
|
<VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<CaptureDropOverlay show={captureDropActive} label={captureDropLabel} />
|
{#if captureDropActive}
|
||||||
|
<div class="capture-drop-overlay">
|
||||||
|
<div class="capture-drop-box">{captureDropLabel}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
|
|
@ -2522,7 +2940,16 @@
|
||||||
</div>
|
</div>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
|
|
||||||
<ErrorBanner {error} onDismiss={() => error = ''} />
|
{#if error}
|
||||||
|
<div class="error-banner" role="button" tabindex="0" on:click={() => error = ''} on:keydown={onKeyActivate(() => error = '')}>
|
||||||
|
{translateError(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}
|
{#if noteEditor}
|
||||||
<!-- Note editor with markdown preview -->
|
<!-- Note editor with markdown preview -->
|
||||||
|
|
@ -2577,42 +3004,172 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
{#if activeTab === 'overview'}
|
{#if activeTab === 'overview'}
|
||||||
<OverviewTab
|
<div class="overview">
|
||||||
node={selectedNode}
|
<h2>{selectedNode.title}</h2>
|
||||||
{notes}
|
<div class="meta-grid">
|
||||||
{worklog}
|
<div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{nodeKindLabel(selectedNode.type)}</span></div>
|
||||||
{nodeKindLabel}
|
<div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{selectedNode.section || '—'}</span></div>
|
||||||
{formatDate}
|
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(selectedNode.createdAt)}</span></div>
|
||||||
on:goTab={(e) => setActiveTab(e.detail)}
|
</div>
|
||||||
on:openNote={(e) => openNote(e.detail.note)}
|
<div class="quick-actions">
|
||||||
on:createNote={() => { setActiveTab('notes'); openCreateNote() }}
|
<button class="qa-btn" on:click={() => { setActiveTab('notes'); openCreateNote() }}>
|
||||||
on:addFile={() => { setActiveTab('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="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>
|
||||||
on:createAction={openCreateAction}
|
{t('overview.newNote')}
|
||||||
/>
|
</button>
|
||||||
|
<button class="qa-btn" on:click={() => { setActiveTab('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>
|
||||||
|
{t('overview.addFile')}
|
||||||
|
</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>
|
||||||
|
{t('overview.addAction')}
|
||||||
|
</button>
|
||||||
|
<button class="qa-btn" on:click={() => setActiveTab('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>
|
||||||
|
{t('overview.logTime')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if notes.length > 0}
|
||||||
|
<div class="recent-section">
|
||||||
|
<h3>{t('overview.recentNotes')}</h3>
|
||||||
|
{#each notes.slice(0, 5) as note}
|
||||||
|
<div class="recent-note" role="button" tabindex="0" on:click={() => openNote(note)} on:keydown={onKeyActivate(() => 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>{t('overview.recentEntries')}</h3>
|
||||||
|
{#each worklog.slice(0, 3) as e}
|
||||||
|
<div class="recent-entry">{e.summary} ({e.minutes} {t('worklog.min')})</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{:else if activeTab === 'notes'}
|
{:else if activeTab === 'notes'}
|
||||||
<NotesTab
|
<div class="notes-tab">
|
||||||
{notes}
|
<div class="tab-toolbar">
|
||||||
showCreateNote={showCreateNote}
|
<button class="btn btn-primary" on:click={openCreateNote}>{t('note.add')}</button>
|
||||||
{formatDate}
|
</div>
|
||||||
on:createNote={openCreateNote}
|
{#if showCreateNote}
|
||||||
on:submitCreateNote={(e) => { newNoteTitle = e.detail.title; submitCreateNote() }}
|
<div class="create-form">
|
||||||
on:cancelCreateNote={cancelCreateNote}
|
<input type="text" placeholder={t('note.title')} bind:value={newNoteTitle}
|
||||||
on:openNote={(e) => openNote(e.detail.note)}
|
on:keydown={(e) => e.key === 'Enter' && submitCreateNote()} />
|
||||||
on:startRename={(e) => startRenameNote(e.detail.note.id, e.detail.note.title)}
|
<div class="form-actions">
|
||||||
on:delete={(e) => deleteNote(e.detail.note)}
|
<button class="btn btn-primary" on:click={submitCreateNote}>{t('common.create')}</button>
|
||||||
/>
|
<button class="btn" on:click={cancelCreateNote}>{t('common.cancel')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if notes.length === 0 && !showCreateNote}
|
||||||
|
<div class="empty-state"><p>{t('note.noNotes')}</p><p class="hint">{t('note.createFirst')}</p></div>
|
||||||
|
{:else}
|
||||||
|
<div class="notes-list">
|
||||||
|
{#each notes as note}
|
||||||
|
<div class="note-card" role="button" tabindex="0" on:click={() => openNote(note)} on:keydown={onKeyActivate(() => openNote(note))}>
|
||||||
|
<div class="note-card-info">
|
||||||
|
<div class="note-card-title">{note.title}</div>
|
||||||
|
<div class="note-card-date">{formatDate(note.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="note-card-actions" on:click|stopPropagation>
|
||||||
|
<button class="note-action-btn" on:click={() => startRenameNote(note.id, note.title)} title={t('common.rename')}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="note-action-btn note-action-danger" on:click={() => deleteNote(note)} title={t('common.delete')}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{:else if activeTab === 'files'}
|
{:else if activeTab === 'files'}
|
||||||
<FilesTab
|
<!-- External file drops are handled by the unified capture flow and land in the selected case inbox. -->
|
||||||
bind:this={filesTabRef}
|
<div class="files-tab">
|
||||||
{selectedNode}
|
<div class="tab-toolbar">
|
||||||
on:openNote={(e) => openNote(e.detail.note)}
|
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
|
||||||
on:refreshParent={(e) => refreshParentNode(e.detail.nodeId)}
|
<button class="btn" on:click={addFolder} disabled={importing}>{t('file.addFolder')}</button>
|
||||||
on:error={(e) => { error = e.detail.message }}
|
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
|
||||||
on:rename={(e) => openRename(e.detail.id, e.detail.name)}
|
{#if clipboard.items.length > 0}
|
||||||
on:confirm={(e) => openConfirm(e.detail)}
|
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadingFiles}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if folderStack.length > 0}
|
||||||
|
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...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>
|
||||||
|
{t('common.backLabel')}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
|
||||||
|
{/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 ? t('file.noFiles') : t('file.noFilesCase')}</p>
|
||||||
|
<p class="hint">{t('file.hint')}</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<button class="btn btn-primary" on:click={addFile}>{t('file.addFileSimple')}</button>
|
||||||
|
<button class="btn" on:click={addFolder}>{t('file.addFolderSimple')}</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>{t('file.scanning')}</p></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{:else if activeTab === 'inbox'}
|
{:else if activeTab === 'inbox'}
|
||||||
<div class="inbox-tab">
|
<div class="inbox-tab">
|
||||||
|
|
@ -3478,6 +4035,30 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showImportDialog && importSummary}
|
||||||
|
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelImport} on:keydown={onKeyActivate(cancelImport)}>
|
||||||
|
<div class="modal">
|
||||||
|
<h3>{t('file.importTitle')} «{selectedNode ? selectedNode.title : ''}»</h3>
|
||||||
|
<div class="import-summary">
|
||||||
|
<div class="summary-row"><span>{t('file.importFiles')}</span><span>{importSummary.files}</span></div>
|
||||||
|
<div class="summary-row"><span>{t('file.importFolders')}</span><span>{importSummary.folders}</span></div>
|
||||||
|
<div class="summary-row"><span>{t('file.importSize')}</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')}>{t('file.importCopy')}</button>
|
||||||
|
<button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button>
|
||||||
|
<button class="btn" on:click={cancelImport}>{t('common.cancel')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showRename}
|
{#if showRename}
|
||||||
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={onKeyActivate(cancelRename)}>
|
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={onKeyActivate(cancelRename)}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<script>
|
|
||||||
export let show = false
|
|
||||||
export let label = ''
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if show}
|
|
||||||
<div class="capture-drop-overlay">
|
|
||||||
<div class="capture-drop-box">{label}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.capture-drop-overlay { position: fixed; inset: 0; z-index: 120; pointer-events: none; display: flex; align-items: center; justify-content: center; background: rgba(19, 19, 31, 0.42); border: 2px dashed #818cf8; }
|
|
||||||
.capture-drop-box { max-width: min(520px, calc(100vw - 48px)); padding: 14px 18px; border: 1px solid #3a3a5c; border-radius: 8px; background: #1a1a28; color: #e4e4ef; font-size: 14px; font-weight: 600; box-shadow: 0 12px 32px rgba(0,0,0,0.35); text-align: center; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<script>
|
|
||||||
import { t } from '../i18n'
|
|
||||||
|
|
||||||
export let error = ''
|
|
||||||
export let onDismiss = () => {}
|
|
||||||
|
|
||||||
function translateError(msg) {
|
|
||||||
const map = {
|
|
||||||
'vault not open': t('error.vaultNotOpen'),
|
|
||||||
}
|
|
||||||
return map[msg] || msg
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e) {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="error-banner" role="button" tabindex="0" on:click={onDismiss} on:keydown={handleKeydown}>
|
|
||||||
{translateError(error)}
|
|
||||||
<button class="dismiss-btn" on:click|stopPropagation={onDismiss} 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}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.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; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
<script>
|
|
||||||
import { t } from '../i18n'
|
|
||||||
import { actionIcon } from '../actionIcons'
|
|
||||||
import { createEventDispatcher } from 'svelte'
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
|
||||||
|
|
||||||
export let node = null
|
|
||||||
export let notes = []
|
|
||||||
export let worklog = []
|
|
||||||
export let nodeKindLabel = (type) => type || ''
|
|
||||||
export let formatDate = (d) => d || ''
|
|
||||||
|
|
||||||
function goNotesCreate() {
|
|
||||||
dispatch('goTab', 'notes')
|
|
||||||
dispatch('createNote')
|
|
||||||
}
|
|
||||||
|
|
||||||
function goFilesAdd() {
|
|
||||||
dispatch('goTab', 'files')
|
|
||||||
dispatch('addFile')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyActivate(fn) {
|
|
||||||
return (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
fn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="overview">
|
|
||||||
<h2 class="node-title">{node.title}</h2>
|
|
||||||
<div class="meta-grid">
|
|
||||||
<div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{nodeKindLabel(node.type)}</span></div>
|
|
||||||
<div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{node.section || '—'}</span></div>
|
|
||||||
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(node.createdAt)}</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="quick-actions">
|
|
||||||
<button class="qa-btn" on:click={goNotesCreate}>
|
|
||||||
<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>
|
|
||||||
{t('overview.newNote')}
|
|
||||||
</button>
|
|
||||||
<button class="qa-btn" on:click={goFilesAdd}>
|
|
||||||
<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>
|
|
||||||
{t('overview.addFile')}
|
|
||||||
</button>
|
|
||||||
<button class="qa-btn" on:click={() => dispatch('createAction')}>
|
|
||||||
<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>
|
|
||||||
{t('overview.addAction')}
|
|
||||||
</button>
|
|
||||||
<button class="qa-btn" on:click={() => dispatch('goTab', '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>
|
|
||||||
{t('overview.logTime')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if notes.length > 0}
|
|
||||||
<div class="recent-section">
|
|
||||||
<h3>{t('overview.recentNotes')}</h3>
|
|
||||||
{#each notes.slice(0, 5) as note}
|
|
||||||
<div class="recent-note" role="button" tabindex="0" on:click={() => dispatch('openNote', { note })} on:keydown={onKeyActivate(() => dispatch('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>{t('overview.recentEntries')}</h3>
|
|
||||||
{#each worklog.slice(0, 3) as e}
|
|
||||||
<div class="recent-entry">{e.summary} ({e.minutes} {t('worklog.min')})</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.overview { padding: 24px; }
|
|
||||||
.node-title { font-size: 24px; margin-bottom: 16px; color: #e4e4ef; }
|
|
||||||
.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; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,616 +0,0 @@
|
||||||
<script>
|
|
||||||
import { createEventDispatcher } from 'svelte'
|
|
||||||
import { t } from '../../i18n'
|
|
||||||
import { isMarkdownFile } from '../../fileUtils.js'
|
|
||||||
import FileTreeRow from '../../../FileTreeRow.svelte'
|
|
||||||
import FileBreadcrumbs from '../../FileBreadcrumbs.svelte'
|
|
||||||
import FilePreviewModal from '../../FilePreviewModal.svelte'
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
|
||||||
|
|
||||||
// Props
|
|
||||||
export let selectedNode = null
|
|
||||||
|
|
||||||
// Internal state
|
|
||||||
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 importing = false
|
|
||||||
let importSummary = null
|
|
||||||
let showImportDialog = false
|
|
||||||
let pendingImportPath = ''
|
|
||||||
let pendingImportParent = ''
|
|
||||||
|
|
||||||
// ===== Wails call helper =====
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Public API for keyboard handler =====
|
|
||||||
export function filesHandleKeydown(e) {
|
|
||||||
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') {
|
|
||||||
if (previewItem) { e.preventDefault(); closePreview(); return }
|
|
||||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
if (previewItem) { closePreview(); return }
|
|
||||||
if (selectedIds.length > 0) { clearSelection(); return }
|
|
||||||
} else if (e.key === 'F2') {
|
|
||||||
e.preventDefault()
|
|
||||||
openRenameForSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Folder navigation =====
|
|
||||||
export 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
|
|
||||||
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) {
|
|
||||||
folderStack = []
|
|
||||||
currentFolderId = null
|
|
||||||
if (selectedNode) 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) {
|
|
||||||
if (item && item.fileId && isMarkdownFile(item)) {
|
|
||||||
try {
|
|
||||||
const action = await wailsCall('CheckFileAction', item.fileId)
|
|
||||||
if (action.action === 'note') {
|
|
||||||
dispatch('openNote', { note: { id: action.noteId, title: action.noteTitle } })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (action.action === 'external') {
|
|
||||||
await wailsCall('OpenFile', item.fileId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('CheckFileAction failed, falling back to preview:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Helpers for preview =====
|
|
||||||
function needsBase64Preview(item) {
|
|
||||||
if (!item) return false
|
|
||||||
const ext = (item.name || '').toLowerCase()
|
|
||||||
return ext.endsWith('.png') || ext.endsWith('.jpg') || ext.endsWith('.jpeg') ||
|
|
||||||
ext.endsWith('.gif') || ext.endsWith('.webp') || ext.endsWith('.svg') ||
|
|
||||||
ext.endsWith('.bmp') || ext.endsWith('.ico') || ext.endsWith('.pdf')
|
|
||||||
}
|
|
||||||
|
|
||||||
function needsTextPreview(item) {
|
|
||||||
if (!item) return false
|
|
||||||
const ext = (item.name || '').toLowerCase()
|
|
||||||
return ext.endsWith('.txt') || ext.endsWith('.log') || ext.endsWith('.json') ||
|
|
||||||
ext.endsWith('.xml') || ext.endsWith('.csv') || ext.endsWith('.md') ||
|
|
||||||
ext.endsWith('.js') || ext.endsWith('.ts') || ext.endsWith('.css') ||
|
|
||||||
ext.endsWith('.html') || ext.endsWith('.yaml') || ext.endsWith('.yml')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== File operations =====
|
|
||||||
async function createFile() {
|
|
||||||
const name = prompt(t('file.namePrompt'))
|
|
||||||
if (!name || !name.trim()) return
|
|
||||||
try {
|
|
||||||
const parentId = currentFolderId || selectedNode.id
|
|
||||||
await wailsCall('CreateEmptyFile', parentId, name.trim())
|
|
||||||
await loadFolder(parentId)
|
|
||||||
dispatch('refreshParent', { nodeId: parentId })
|
|
||||||
} catch (e) {
|
|
||||||
dispatch('error', { message: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function duplicateItem(id) {
|
|
||||||
try {
|
|
||||||
await wailsCall('DuplicateNode', id)
|
|
||||||
const parentId = currentFolderId || selectedNode.id
|
|
||||||
await loadFolder(parentId)
|
|
||||||
dispatch('refreshParent', { nodeId: parentId })
|
|
||||||
} catch (e) {
|
|
||||||
dispatch('error', { message: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renameItem(id) {
|
|
||||||
const item = fileItems.find(x => x.id === id)
|
|
||||||
if (item) {
|
|
||||||
dispatch('rename', { id, name: 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) {
|
|
||||||
dispatch('error', { message: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Selection =====
|
|
||||||
function getTargetIds(ids) {
|
|
||||||
return ids.length > 0 ? ids : fileItems.map(x => x.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelection(id) {
|
|
||||||
if (selectedIds.includes(id)) {
|
|
||||||
selectedIds = selectedIds.filter(x => x !== id)
|
|
||||||
} else {
|
|
||||||
selectedIds = [...selectedIds, id]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectOne(id) {
|
|
||||||
selectedIds = [id]
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectAll() {
|
|
||||||
selectedIds = fileItems.map(x => x.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelection() {
|
|
||||||
selectedIds = []
|
|
||||||
}
|
|
||||||
|
|
||||||
function 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 cutSelected() {
|
|
||||||
const ids = getTargetIds(selectedIds)
|
|
||||||
clipboard = { items: ids, mode: 'cut' }
|
|
||||||
selectedIds = []
|
|
||||||
}
|
|
||||||
|
|
||||||
function copySelected() {
|
|
||||||
const ids = getTargetIds(selectedIds)
|
|
||||||
clipboard = { items: ids, mode: 'copy' }
|
|
||||||
selectedIds = []
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openRenameForSelection() {
|
|
||||||
if (selectedIds.length === 1) {
|
|
||||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
|
||||||
if (item) {
|
|
||||||
dispatch('rename', { id: item.id, name: item.name })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSelected() {
|
|
||||||
const ids = getTargetIds(selectedIds)
|
|
||||||
const item = fileItems.find(x => x.id === ids[0])
|
|
||||||
let label
|
|
||||||
if (ids.length === 1 && item?.type === 'folder') {
|
|
||||||
label = t('delete.folder')
|
|
||||||
} else if (ids.length === 1) {
|
|
||||||
label = t('delete.file')
|
|
||||||
} else {
|
|
||||||
label = t('delete.files', { count: ids.length })
|
|
||||||
}
|
|
||||||
dispatch('confirm', {
|
|
||||||
title: t('delete.confirmTitle'),
|
|
||||||
message: t('delete.confirmMessage') + ' ' + label + '?',
|
|
||||||
confirmText: t('common.delete'),
|
|
||||||
danger: true,
|
|
||||||
onConfirm: async () => {
|
|
||||||
for (const id of ids) {
|
|
||||||
try {
|
|
||||||
await wailsCall('DeleteFileOrFolder', id)
|
|
||||||
} catch (e) {
|
|
||||||
dispatch('error', { message: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectedIds = []
|
|
||||||
const reloadId = currentFolderId || selectedNode.id
|
|
||||||
await loadFolder(reloadId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Drag-and-drop =====
|
|
||||||
function onDragStart(e, id) {
|
|
||||||
e.stopPropagation()
|
|
||||||
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.stopPropagation()
|
|
||||||
e.dataTransfer.dropEffect = 'move'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDrop(e, folderId) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
if (dragIds.length === 0) return
|
|
||||||
for (const id of dragIds) {
|
|
||||||
try {
|
|
||||||
await wailsCall('MoveNode', id, folderId)
|
|
||||||
} catch (e) {
|
|
||||||
dispatch('error', { message: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragIds = []
|
|
||||||
selectedIds = []
|
|
||||||
await loadFolder(currentFolderId || selectedNode.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Import =====
|
|
||||||
async function addFile() {
|
|
||||||
const path = await wailsCall('PickFile')
|
|
||||||
if (!path) return
|
|
||||||
const parentId = currentFolderId || selectedNode.id
|
|
||||||
await startImport(parentId, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addFolder() {
|
|
||||||
const path = await wailsCall('PickDirectory')
|
|
||||||
if (!path) return
|
|
||||||
const parentId = currentFolderId || selectedNode.id
|
|
||||||
await startImport(parentId, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startImport(parentID, sourcePath) {
|
|
||||||
importing = true
|
|
||||||
try {
|
|
||||||
const summary = await wailsCall('PreviewImport', sourcePath)
|
|
||||||
importSummary = summary
|
|
||||||
pendingImportPath = sourcePath
|
|
||||||
pendingImportParent = parentID
|
|
||||||
showImportDialog = true
|
|
||||||
} catch (e) {
|
|
||||||
dispatch('error', { message: String(e) })
|
|
||||||
}
|
|
||||||
importing = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmImport(mode) {
|
|
||||||
try {
|
|
||||||
const parentId = pendingImportParent || selectedNode.id
|
|
||||||
const result = mode === 'copy'
|
|
||||||
? await wailsCall('AddPathCopy', parentId, pendingImportPath)
|
|
||||||
: await wailsCall('AddPathLink', parentId, pendingImportPath)
|
|
||||||
showImportDialog = false
|
|
||||||
importSummary = null
|
|
||||||
folderStack = []
|
|
||||||
currentFolderId = null
|
|
||||||
await loadFolder(parentId)
|
|
||||||
dispatch('refreshParent', { nodeId: parentId })
|
|
||||||
} catch (e) {
|
|
||||||
dispatch('error', { message: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelImport() {
|
|
||||||
showImportDialog = false
|
|
||||||
importSummary = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFile({ id, type }) {
|
|
||||||
const label = type === 'folder' ? t('delete.folder') : t('delete.file')
|
|
||||||
dispatch('confirm', {
|
|
||||||
title: t('delete.confirmTitle'),
|
|
||||||
message: t('delete.confirmMessage') + ' ' + label + '?',
|
|
||||||
confirmText: t('common.delete'),
|
|
||||||
danger: true,
|
|
||||||
onConfirm: async () => {
|
|
||||||
try {
|
|
||||||
await wailsCall('DeleteFileOrFolder', id)
|
|
||||||
const reloadId = currentFolderId || selectedNode.id
|
|
||||||
await loadFolder(reloadId)
|
|
||||||
} catch (e) {
|
|
||||||
dispatch('error', { message: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Public API: called from parent when node changes =====
|
|
||||||
export function resetToNode(nodeId) {
|
|
||||||
folderStack = []
|
|
||||||
currentFolderId = null
|
|
||||||
selectedIds = []
|
|
||||||
dragIds = []
|
|
||||||
clipboard = { items: [], mode: 'copy' }
|
|
||||||
fileItems = []
|
|
||||||
loadingFiles = false
|
|
||||||
previewItem = null
|
|
||||||
previewContent = ''
|
|
||||||
previewError = ''
|
|
||||||
importing = false
|
|
||||||
showImportDialog = false
|
|
||||||
importSummary = null
|
|
||||||
if (nodeId) loadFolder(nodeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load folder when selectedNode changes
|
|
||||||
$: if (selectedNode && selectedNode.id && !currentFolderId && fileItems.length === 0 && !loadingFiles) {
|
|
||||||
loadFolder(selectedNode.id)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="files-tab">
|
|
||||||
<div class="tab-toolbar">
|
|
||||||
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
|
|
||||||
<button class="btn" on:click={addFolder} disabled={importing}>{t('file.addFolder')}</button>
|
|
||||||
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
|
|
||||||
{#if clipboard.items.length > 0}
|
|
||||||
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loadingFiles}
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>{t('common.loading')}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#if folderStack.length > 0}
|
|
||||||
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
|
|
||||||
const i = e.detail
|
|
||||||
if (i === 0) {
|
|
||||||
folderStack = []
|
|
||||||
currentFolderId = null
|
|
||||||
if (selectedNode) 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>
|
|
||||||
{t('common.backLabel')}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
|
|
||||||
{/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 ? t('file.noFiles') : t('file.noFilesCase')}</p>
|
|
||||||
<p class="hint">{t('file.hint')}</p>
|
|
||||||
<div class="empty-actions">
|
|
||||||
<button class="btn btn-primary" on:click={addFile}>{t('file.addFileSimple')}</button>
|
|
||||||
<button class="btn" on:click={addFolder}>{t('file.addFolderSimple')}</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>{t('file.scanning')}</p></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Import dialog -->
|
|
||||||
{#if showImportDialog && importSummary}
|
|
||||||
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label={t('file.importTitle')} on:click|self={cancelImport} on:keydown={(e) => e.key === 'Escape' && cancelImport()}>
|
|
||||||
<div class="modal-box" role="document">
|
|
||||||
<h3>{t('file.importTitle')}</h3>
|
|
||||||
<div class="import-summary">
|
|
||||||
<p><strong>{t('file.path')}:</strong> {importSummary.path || pendingImportPath}</p>
|
|
||||||
{#if importSummary.files}
|
|
||||||
<p>{t('file.files')}: {importSummary.files.length}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>{t('file.importCopy')}</button>
|
|
||||||
<button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button>
|
|
||||||
<button class="btn" on:click={cancelImport}>{t('common.cancel')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- File preview modal -->
|
|
||||||
{#if previewItem}
|
|
||||||
<FilePreviewModal
|
|
||||||
item={previewItem}
|
|
||||||
content={previewContent}
|
|
||||||
loading={previewLoading}
|
|
||||||
error={previewError}
|
|
||||||
{wailsCall}
|
|
||||||
on:close={closePreview}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.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; }
|
|
||||||
.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 .hint { font-size: 12px; color: #555; margin-top: 6px; }
|
|
||||||
.empty-icon { margin-bottom: 12px; color: #333; }
|
|
||||||
.empty-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
|
|
||||||
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
|
|
||||||
.btn:hover { background: #222233; }
|
|
||||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
|
|
||||||
.btn-primary:hover { background: #4f46e5; }
|
|
||||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
||||||
.modal-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; }
|
|
||||||
.modal-box { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 12px; padding: 24px; min-width: 320px; max-width: 560px; width: 90vw; }
|
|
||||||
.modal-box h3 { margin: 0 0 16px; font-size: 16px; }
|
|
||||||
.import-summary { margin-bottom: 16px; font-size: 13px; }
|
|
||||||
.import-summary p { margin: 4px 0; }
|
|
||||||
.form-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
<script>
|
|
||||||
import { t } from '../../i18n'
|
|
||||||
import { createEventDispatcher } from 'svelte'
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
|
||||||
|
|
||||||
export let notes = []
|
|
||||||
export let showCreateNote = false
|
|
||||||
export let formatDate = (d) => d || ''
|
|
||||||
|
|
||||||
let newNoteTitle = ''
|
|
||||||
|
|
||||||
function handleCreateNote() {
|
|
||||||
dispatch('createNote')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmitCreateNote() {
|
|
||||||
if (newNoteTitle.trim()) {
|
|
||||||
dispatch('submitCreateNote', { title: newNoteTitle.trim() })
|
|
||||||
newNoteTitle = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancelCreateNote() {
|
|
||||||
dispatch('cancelCreateNote')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenNote(note) {
|
|
||||||
dispatch('openNote', { note })
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStartRename(note) {
|
|
||||||
dispatch('startRename', { note })
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete(note) {
|
|
||||||
dispatch('delete', { note })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyActivate(fn) {
|
|
||||||
return (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
fn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="notes-tab">
|
|
||||||
<div class="tab-toolbar">
|
|
||||||
<button class="btn btn-primary" on:click={handleCreateNote}>{t('note.add')}</button>
|
|
||||||
</div>
|
|
||||||
{#if showCreateNote}
|
|
||||||
<div class="create-form">
|
|
||||||
<input type="text" placeholder={t('note.title')} bind:value={newNoteTitle}
|
|
||||||
on:keydown={(e) => e.key === 'Enter' && handleSubmitCreateNote()} />
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn btn-primary" on:click={handleSubmitCreateNote}>{t('common.create')}</button>
|
|
||||||
<button class="btn" on:click={handleCancelCreateNote}>{t('common.cancel')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if notes.length === 0 && !showCreateNote}
|
|
||||||
<div class="empty-state"><p>{t('note.noNotes')}</p><p class="hint">{t('note.createFirst')}</p></div>
|
|
||||||
{:else}
|
|
||||||
<div class="notes-list">
|
|
||||||
{#each notes as note}
|
|
||||||
<div class="note-card" role="button" tabindex="0" on:click={() => handleOpenNote(note)} on:keydown={onKeyActivate(() => handleOpenNote(note))}>
|
|
||||||
<div class="note-card-info">
|
|
||||||
<div class="note-card-title">{note.title}</div>
|
|
||||||
<div class="note-card-date">{formatDate(note.createdAt)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="note-card-actions" on:click|stopPropagation>
|
|
||||||
<button class="note-action-btn" on:click={() => handleStartRename(note)} title={t('common.rename')}>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="note-action-btn note-action-danger" on:click={() => handleDelete(note)} title={t('common.delete')}>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.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; outline: none; }
|
|
||||||
.create-form input:focus { 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; }
|
|
||||||
.note-card-info { flex: 1; min-width: 0; }
|
|
||||||
.note-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; }
|
|
||||||
.note-card:hover .note-card-actions { opacity: 1; }
|
|
||||||
.note-action-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: 4px; background: transparent; color: #666; cursor: pointer; transition: background 0.12s, color 0.12s; }
|
|
||||||
.note-action-btn:hover { background: #2a2a3c; color: #ccc; }
|
|
||||||
.note-action-danger:hover { background: rgba(239, 68, 68, 0.15); color: #f87171; }
|
|
||||||
.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 .hint { font-size: 12px; color: #555; margin-top: 6px; }
|
|
||||||
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
|
|
||||||
.btn:hover { background: #222233; }
|
|
||||||
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
|
|
||||||
.btn-primary:hover { background: #4f46e5; }
|
|
||||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/**
|
|
||||||
* Actions API — actions (quick commands) on nodes.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const listActions = createApi('ListActions')
|
|
||||||
export const createAction = createApi('CreateAction')
|
|
||||||
export const runAction = createApi('RunAction')
|
|
||||||
export const deleteAction = createApi('DeleteAction')
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
/**
|
|
||||||
* Activity API — activity feed and per-node activity.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const listActivityFeed = createApi('ListActivityFeed')
|
|
||||||
export const listActivityByNode = createApi('ListActivityByNode')
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/**
|
|
||||||
* Browser Events API — browser extension events.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const listBrowserEvents = createApi('ListBrowserEvents')
|
|
||||||
export const acceptBrowserEvent = createApi('AcceptBrowserEvent')
|
|
||||||
export const dismissBrowserEvent = createApi('DismissBrowserEvent')
|
|
||||||
export const attachBrowserEventToNode = createApi('AttachBrowserEventToNode')
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
/**
|
|
||||||
* Files API — all file/folder related Wails calls.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const listItems = createApi('ListItems')
|
|
||||||
export const listFiles = createApi('ListFiles')
|
|
||||||
export const createEmptyFile = createApi('CreateEmptyFile')
|
|
||||||
export const deleteFileOrFolder = createApi('DeleteFileOrFolder')
|
|
||||||
export const openFile = createApi('OpenFile')
|
|
||||||
export const openFolder = createApi('OpenFolder')
|
|
||||||
export const pickFile = createApi('PickFile')
|
|
||||||
export const pickDirectory = createApi('PickDirectory')
|
|
||||||
export const previewImport = createApi('PreviewImport')
|
|
||||||
export const addPathCopy = createApi('AddPathCopy')
|
|
||||||
export const addPathLink = createApi('AddPathLink')
|
|
||||||
export const checkFileAction = createApi('CheckFileAction')
|
|
||||||
export const getFileBase64 = createApi('GetFileBase64')
|
|
||||||
export const readFileText = createApi('ReadFileText')
|
|
||||||
export const showInFolder = createApi('OpenFolder')
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/**
|
|
||||||
* Inbox API — capture and inbox management.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const listInboxNodes = createApi('ListInboxNodes')
|
|
||||||
export const listInboxNodesForTarget = createApi('ListInboxNodesForTarget')
|
|
||||||
export const captureClipboardTextWithContext = createApi('CaptureClipboardTextWithContext')
|
|
||||||
export const captureTextWithContext = createApi('CaptureTextWithContext')
|
|
||||||
export const captureURLWithContext = createApi('CaptureURLWithContext')
|
|
||||||
export const capturePathWithContext = createApi('CapturePathWithContext')
|
|
||||||
export const captureFileDataWithContext = createApi('CaptureFileDataWithContext')
|
|
||||||
export const resolveInboxNode = createApi('ResolveInboxNode')
|
|
||||||
export const deleteInboxNode = createApi('DeleteInboxNode')
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Journal & Worklog API — time tracking and reporting.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const listWorklog = createApi('ListWorklog')
|
|
||||||
export const createWorklogFull = createApi('CreateWorklogFull')
|
|
||||||
export const updateWorklogEntry = createApi('UpdateWorklogEntry')
|
|
||||||
export const deleteWorklogEntry = createApi('DeleteWorklogEntry')
|
|
||||||
export const getWorklogEntryEvents = createApi('GetWorklogEntryEvents')
|
|
||||||
export const listWorklogReport = createApi('ListWorklogReport')
|
|
||||||
export const worklogReportSummary = createApi('WorklogReportSummary')
|
|
||||||
export const saveWorklogReport = createApi('SaveWorklogReport')
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/**
|
|
||||||
* Links API — external URL links management.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const listLinks = createApi('ListLinks')
|
|
||||||
export const updateLink = createApi('UpdateLink')
|
|
||||||
export const deleteLink = createApi('DeleteLink')
|
|
||||||
export const openLink = createApi('OpenLink')
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
/**
|
|
||||||
* Node & Workspace API — tree, node CRUD, system views.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const getStartupStatus = createApi('GetStartupStatus')
|
|
||||||
export const verstakVersion = createApi('VerstakVersion')
|
|
||||||
export const listSystemViewsWithPlugins = createApi('ListSystemViewsWithPlugins')
|
|
||||||
export const listWorkspaceTree = createApi('ListWorkspaceTree')
|
|
||||||
export const listWorkspaceChildren = createApi('ListWorkspaceChildren')
|
|
||||||
export const listEnabledTemplates = createApi('ListEnabledTemplates')
|
|
||||||
export const createNodeFromTemplate = createApi('CreateNodeFromTemplate')
|
|
||||||
export const deleteNode = createApi('DeleteNode')
|
|
||||||
export const renameNode = createApi('RenameNode')
|
|
||||||
export const moveNode = createApi('MoveNode')
|
|
||||||
export const duplicateNode = createApi('DuplicateNode')
|
|
||||||
export const getNodeDetail = createApi('GetNodeDetail')
|
|
||||||
export const getSuggestions = createApi('GetSuggestions')
|
|
||||||
export const validateName = createApi('ValidateName')
|
|
||||||
export const writeDebugLog = createApi('WriteDebugLog')
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
/**
|
|
||||||
* Notes API — all note-related Wails calls.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const listNotes = createApi('ListNotes')
|
|
||||||
export const createNote = createApi('CreateNote')
|
|
||||||
export const readNote = createApi('ReadNote')
|
|
||||||
export const saveNote = createApi('SaveNote')
|
|
||||||
export const renameNote = createApi('RenameNote')
|
|
||||||
export const deleteNoteApi = createApi('DeleteNote')
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/**
|
|
||||||
* Search API — global search and node lookup.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const searchNodes = createApi('SearchNodes')
|
|
||||||
export const getNodeDetail = createApi('GetNodeDetail')
|
|
||||||
export const searchWorkspace = createApi('SearchWorkspace')
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/**
|
|
||||||
* Suggestions API — worklog suggestions.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const getSuggestions = createApi('GetSuggestions')
|
|
||||||
export const acceptSuggestionFull = createApi('AcceptSuggestionFull')
|
|
||||||
export const dismissSuggestion = createApi('DismissSuggestion')
|
|
||||||
export const acceptSuggestionWith = createApi('AcceptSuggestionWith')
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
/**
|
|
||||||
* Sync API — vault synchronisation.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const syncStatus = createApi('SyncStatus')
|
|
||||||
export const syncNow = createApi('SyncNow')
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/**
|
|
||||||
* Today API — today dashboard, in-progress items, captures.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const listTodayView = createApi('ListTodayView')
|
|
||||||
export const listTodayInProgress = createApi('ListTodayInProgress')
|
|
||||||
export const listTodayCaptures = createApi('ListTodayCaptures')
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/**
|
|
||||||
* Trash API — trash operations.
|
|
||||||
*/
|
|
||||||
import { createApi } from './wails.js'
|
|
||||||
|
|
||||||
export const listTrash = createApi('ListTrash')
|
|
||||||
export const trashCount = createApi('TrashCount')
|
|
||||||
export const readTrashFile = createApi('ReadTrashFile')
|
|
||||||
export const readTrashFileContent = createApi('ReadTrashFileContent')
|
|
||||||
export const restoreTrashNodes = createApi('RestoreTrashNodesJSON')
|
|
||||||
export const purgeTrashNodes = createApi('PurgeTrashNodesJSON')
|
|
||||||
export const emptyTrash = createApi('EmptyTrash')
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* Base Wails API call helper.
|
|
||||||
* All backend communication goes through this function.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a wrapped API function with consistent error handling.
|
|
||||||
*/
|
|
||||||
export function createApi(method) {
|
|
||||||
return (...args) => {
|
|
||||||
return wailsCall(method, ...args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue