Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b3de296b9 | ||
|
|
cd982f2f28 | ||
|
|
9878ab4fb3 | ||
|
|
490a3dd624 |
@@ -210,46 +210,6 @@ func (a *App) CheckFileAction(fileID string) (*PreflightFileAction, error) {
|
|||||||
return &PreflightFileAction{Action: "preview", FileName: fileRec.Filename}, nil
|
return &PreflightFileAction{Action: "preview", FileName: fileRec.Filename}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveFileText saves text content to a file record.
|
|
||||||
// Only works for vault-contained text files (not binary).
|
|
||||||
func (a *App) SaveFileText(fileID string, content string) error {
|
|
||||||
if err := a.requireVault(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fileRec, err := a.files.Get(fileID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get file: %w", err)
|
|
||||||
}
|
|
||||||
// Safety: only save text-like files
|
|
||||||
name := strings.ToLower(fileRec.Filename)
|
|
||||||
isText := strings.HasPrefix(fileRec.MIME, "text/") ||
|
|
||||||
strings.HasSuffix(name, ".md") ||
|
|
||||||
strings.HasSuffix(name, ".txt") ||
|
|
||||||
strings.HasSuffix(name, ".json") ||
|
|
||||||
strings.HasSuffix(name, ".yaml") ||
|
|
||||||
strings.HasSuffix(name, ".yml") ||
|
|
||||||
strings.HasSuffix(name, ".csv") ||
|
|
||||||
strings.HasSuffix(name, ".xml") ||
|
|
||||||
strings.HasSuffix(name, ".ini") ||
|
|
||||||
strings.HasSuffix(name, ".conf") ||
|
|
||||||
strings.HasSuffix(name, ".sh") ||
|
|
||||||
strings.HasSuffix(name, ".py") ||
|
|
||||||
strings.HasSuffix(name, ".js") ||
|
|
||||||
strings.HasSuffix(name, ".ts") ||
|
|
||||||
strings.HasSuffix(name, ".css") ||
|
|
||||||
strings.HasSuffix(name, ".html") ||
|
|
||||||
strings.HasSuffix(name, ".log")
|
|
||||||
if !isText {
|
|
||||||
return fmt.Errorf("refusing to save binary file: %s", fileRec.Filename)
|
|
||||||
}
|
|
||||||
if err := a.files.WriteText(fileRec, content); err != nil {
|
|
||||||
return fmt.Errorf("write file: %w", err)
|
|
||||||
}
|
|
||||||
// Record activity
|
|
||||||
_ = a.activity.Record(fileRec.NodeID, activity.TargetFile, fileID, "", activity.TypeFileModified, fileRec.Filename, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
||||||
if err := a.requireVault(); err != nil {
|
if err := a.requireVault(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"verstak/internal/core/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// appLogPath returns the path to the application log file.
|
|
||||||
// Uses ~/.local/state/verstak/logs/verstak.log on Linux,
|
|
||||||
// or falls back to ~/.config/verstak/logs/verstak.log.
|
|
||||||
func appLogPath() string {
|
|
||||||
// Prefer XDG_STATE_HOME, then fallback to config dir
|
|
||||||
stateDir := os.Getenv("XDG_STATE_HOME")
|
|
||||||
if stateDir == "" {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err == nil {
|
|
||||||
stateDir = filepath.Join(home, ".local", "state")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stateDir != "" {
|
|
||||||
dir := filepath.Join(stateDir, "verstak", "logs")
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err == nil {
|
|
||||||
return filepath.Join(dir, "verstak.log")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback to config dir
|
|
||||||
cfgDir, err := config.EnsureConfigDir()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
dir := filepath.Join(cfgDir, "logs")
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(dir, "verstak.log")
|
|
||||||
}
|
|
||||||
|
|
||||||
// appLog writes a timestamped line to the application log file.
|
|
||||||
func appLog(level, msg string) {
|
|
||||||
logPath := appLogPath()
|
|
||||||
if logPath == "" {
|
|
||||||
log.Printf("[%s] %s", level, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("2006-01-02T15:04:05"), level, msg)
|
|
||||||
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[%s] %s", level, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
f.WriteString(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FrontendLog receives log messages from the frontend runtime.
|
|
||||||
// Called from JS via Wails binding. Works even if vault is not open.
|
|
||||||
// level: "info", "warn", "error"
|
|
||||||
// message: human-readable message
|
|
||||||
// stack: JS stack trace (optional, only for errors)
|
|
||||||
func (a *App) FrontendLog(level, message, stack string) {
|
|
||||||
msg := "[frontend] " + message
|
|
||||||
if stack != "" {
|
|
||||||
msg += "\n stack: " + stack
|
|
||||||
}
|
|
||||||
// Always log to Go's standard logger (visible in dev mode console)
|
|
||||||
log.Printf("[frontend][%s] %s", level, message)
|
|
||||||
// Persist to log file
|
|
||||||
appLog("frontend-"+level, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogStartupStep logs a startup diagnostic step.
|
|
||||||
// Called from Go side to trace initialization progress.
|
|
||||||
func (a *App) LogStartupStep(step string, success bool, detail string) {
|
|
||||||
status := "ok"
|
|
||||||
if !success {
|
|
||||||
status = "fail"
|
|
||||||
}
|
|
||||||
msg := fmt.Sprintf("[startup] %s: %s", step, status)
|
|
||||||
if detail != "" {
|
|
||||||
msg += " — " + detail
|
|
||||||
}
|
|
||||||
log.Print(msg)
|
|
||||||
appLog("startup", msg)
|
|
||||||
}
|
|
||||||
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
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-B99YW--H.js"></script>
|
<script type="module" crossorigin src="/assets/main-C0D__Sxo.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-CtgLvi_n.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-Bl-yCbt2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+140
-195
@@ -1,216 +1,161 @@
|
|||||||
# Frontend Architecture — Verstak GUI
|
# Frontend Architecture
|
||||||
|
|
||||||
## Tech Stack
|
## Overview
|
||||||
|
|
||||||
- **Framework**: Svelte 4 (runes-free, compiler-only)
|
Verstak frontend is a Svelte 3 application running inside Wails v2 (Go bridge).
|
||||||
- **Build tool**: Vite 5
|
The app manages a hierarchical vault of nodes (folders/cases, notes, files, links, actions)
|
||||||
- **GUI shell**: Wails v2 (Go backend + WebKit GTK frontend)
|
with sync capabilities, worklog/journal, and activity tracking.
|
||||||
- **Language**: Plain JavaScript (no TypeScript in Svelte files — `lang="ts"` is NOT used)
|
|
||||||
|
|
||||||
## Source Structure
|
## 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/
|
frontend/src/
|
||||||
main.js # Entry: mounts App.svelte, global error handlers
|
├── App.svelte # Root component (being modularised)
|
||||||
App.svelte # Root component: sidebar, header, modals, tab router
|
├── TreeNode.svelte # Tree node for sidebar (inline)
|
||||||
FileTreeRow.svelte # Sidebar tree node row
|
├── FileTreeRow.svelte # File row in file tab (inline)
|
||||||
TreeNode.svelte # Recursive tree node
|
├── wailsjs/go/main/App.js # Auto-generated Wails bindings
|
||||||
lib/
|
├── lib/
|
||||||
AppHeader.svelte # Top bar with title, search, actions
|
│ ├── components/ # Reusable UI components
|
||||||
BrowserEvents.svelte # Browser extension events panel
|
│ │ └── notes/
|
||||||
CalendarPluginPage.svelte # Plugin page host
|
│ │ ├── NoteEditorPanel.svelte
|
||||||
ConfirmModal.svelte # Reusable confirm dialog
|
│ │ ├── MarkdownEditor.svelte
|
||||||
FileBreadcrumbs.svelte # File tab breadcrumb navigation
|
│ │ ├── MarkdownPreview.svelte
|
||||||
FileIcon.svelte # Icon resolver for file types
|
│ │ ├── InternalLinkPicker.svelte
|
||||||
FilePreviewModal.svelte # File content preview modal
|
│ │ └── ObjectPickerModal.svelte
|
||||||
FirstRun.svelte # First-run wizard
|
│ ├── services/ # API/Data access layer
|
||||||
GlobalSearch.svelte # Global search bar + results
|
│ │ ├── wails.js # Base Wails call helper
|
||||||
SettingsWindow.svelte # Settings modal container
|
│ │ ├── notes.js # Notes API
|
||||||
Settings*.svelte # Settings sub-sections (General, Sync, etc.)
|
│ │ ├── files.js # Files API
|
||||||
SyncStatus.svelte # Sync status badge
|
│ │ ├── search.js # Search API
|
||||||
TemplateIcon.svelte # Template type icon
|
│ │ ├── inbox.js # Inbox API
|
||||||
TodayScreen.svelte # Today dashboard
|
│ │ ├── trash.js # Trash API
|
||||||
VaultRecovery.svelte # Vault recovery wizard
|
│ │ ├── sync.js # Sync API
|
||||||
actionIcons.js # SVG icon strings
|
│ │ ├── journal.js # Journal/Worklog API
|
||||||
fileUtils.js # File type helpers (canPreview, isMarkdown, etc.)
|
│ │ ├── actions.js # Actions API
|
||||||
i18n/ # Internationalization (en, ru)
|
│ │ ├── links.js # Links API
|
||||||
markdown/ # Markdown rendering + internal links
|
│ │ └── activity.js # Activity API
|
||||||
util/ # Keyboard layout helper
|
│ ├── state/ # State management (planned)
|
||||||
components/
|
│ │ ├── navigation.js # Navigation state
|
||||||
OverviewTab.svelte # Overview tab: meta, quick actions, recent items
|
│ │ └── uiState.js # UI state
|
||||||
files/
|
│ ├── markdown/ # Markdown processing
|
||||||
FilesTab.svelte # Files tab: file browser, preview, import, rename
|
│ │ ├── markdown.ts
|
||||||
notes/
|
│ │ └── internalLinks.ts
|
||||||
NotesTab.svelte # Notes tab: note list, create form
|
│ ├── i18n/ # Internationalisation
|
||||||
MarkdownEditor.svelte # Markdown textarea with toolbar
|
│ │ ├── index.js
|
||||||
MarkdownPreview.svelte # Rendered markdown preview
|
│ │ └── locales/
|
||||||
NoteEditorPanel.svelte # Editor + preview layout, public API: insertText()
|
│ │ ├── en.js
|
||||||
InternalLinkPicker.svelte # Object picker for verstak:// links
|
│ │ └── ru.js
|
||||||
ObjectPickerModal.svelte # Legacy object picker modal
|
│ ├── 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Role of App.svelte
|
## Wails Bridge
|
||||||
|
|
||||||
`App.svelte` is the **root component**. It owns:
|
All backend calls go through `window.go.main.App[method](...)`.
|
||||||
|
The `wailsCall()` helper in `lib/services/wails.js` provides error handling.
|
||||||
|
|
||||||
1. **Global UI state**: sidebar (system views, workspace tree), active tab, selected node/section
|
## Planned Components (to extract from App.svelte)
|
||||||
2. **Lifecycle**: startup checks (GetStartupStatus, VerstakVersion, ListWorkspaceTree), event listeners
|
|
||||||
3. **Top-level modals**: confirm, node rename, import (removed — now in FilesTab), worklog, inbox assign, link edit, settings, trash preview
|
|
||||||
4. **Cross-cutting concerns**: navigation history (goBack, rememberNavigation), keyboard shortcuts, drag-and-drop orchestration, capture/inbox flow
|
|
||||||
5. **Note editor lifecycle**: `noteEditor` state, `doOpenNote()`, `saveCurrentNote()`, `closeNoteEditor()`, link modal, internal link picker
|
|
||||||
|
|
||||||
App.svelte is **NOT** responsible for:
|
### Layout
|
||||||
- **Files tab** → `FilesTab.svelte` (owns all file browser state, preview, import, rename)
|
- `AppShell.svelte` — root layout wrapper
|
||||||
- **Notes tab list** → `NotesTab.svelte` (owns note list UI, create form; editor stays in App)
|
- `Sidebar.svelte` — navigation sidebar
|
||||||
- **Overview tab** → `OverviewTab.svelte` (pure display: meta, quick actions, recent items)
|
- `MainWorkspace.svelte` — main content area
|
||||||
- **Settings sections** → each has its own `Settings*.svelte`
|
|
||||||
|
|
||||||
## Component Communication
|
### 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`
|
||||||
|
|
||||||
### Props (parent → child)
|
### Modals
|
||||||
Data flows down via Svelte `export let prop`
|
- `CreateNodeModal.svelte`
|
||||||
|
- `WorklogModal.svelte`
|
||||||
|
- `CreateActionModal.svelte`
|
||||||
|
- `ImportModal.svelte`
|
||||||
|
- `RenameModal.svelte`
|
||||||
|
- `AssignInboxModal.svelte`
|
||||||
|
- `EditLinkModal.svelte`
|
||||||
|
- `LinkInsertModal.svelte`
|
||||||
|
- `NoteRenameModal.svelte`
|
||||||
|
- `ContextMenu.svelte`
|
||||||
|
|
||||||
### Events (child → parent)
|
## Data Flow
|
||||||
Children dispatch events via `createEventDispatcher()`
|
|
||||||
|
|
||||||
### Public API (bind:this)
|
1. User interacts with UI component
|
||||||
Parent gets imperative handle via `bind:this={ref}` and calls:
|
2. Component calls a service function (e.g., `notesApi.createNote(...)`)
|
||||||
- `ref.publicMethod(args)` — guard with optional chaining: `ref?.method?.(args)`
|
3. Service calls `wailsCall('CreateNote', ...)`
|
||||||
|
4. Wails bridge forwards to Go backend
|
||||||
|
5. Go backend returns result → Wails → service → component updates state
|
||||||
|
|
||||||
## Component Reference
|
## State Management
|
||||||
|
|
||||||
### OverviewTab (`lib/components/OverviewTab.svelte`)
|
Currently all state lives in App.svelte as local variables.
|
||||||
**Props**: `selectedNode`, `notes`, `worklog`, `formatDate`, `nodeKindLabel`
|
Target: extract into `lib/state/navigation.js` and `lib/state/uiState.js`.
|
||||||
**Events**: `createNote`, `addFile`, `createAction`, `switchTab`, `openNote`
|
|
||||||
**State**: None (pure display)
|
|
||||||
|
|
||||||
### NotesTab (`lib/components/notes/NotesTab.svelte`)
|
### Files Flow
|
||||||
**Props**: `notes`, `formatDate`
|
|
||||||
**Events**: `submitCreateNote`({title}), `openNote`({note}), `startRename`({noteId, currentTitle}), `deleteNote`({note})
|
|
||||||
**State**: `showCreateNote`, `newNoteTitle`
|
|
||||||
|
|
||||||
### FilesTab (`lib/components/files/FilesTab.svelte`)
|
- **Component:** `lib/components/files/FilesTab.svelte` — self-contained file browser
|
||||||
**Props**: `selectedNode`, `wailsCall`
|
- **API services:** `lib/services/files.js`, `lib/services/nodes.js`
|
||||||
**Events**: `openNote`({id, title}), `refreshParent`({nodeId}), `error`({message})
|
- **Events emitted:**
|
||||||
**Public API** (via bind:this):
|
- `on:openNote` — when a .md file linked to a note is opened
|
||||||
- `resetToNode(nodeId)` — reset to root of given node
|
- `on:refreshParent` — after file operations that modify the tree
|
||||||
- `addFile()` — open file picker and import
|
- `on:error` — on operation failures
|
||||||
- `loadFolder(folderId)` — load folder contents
|
- `on:rename` — requests parent to show rename modal
|
||||||
- `openFileById(fileNodeId)` — find and preview file
|
- `on:confirm` — requests parent to show confirm dialog
|
||||||
- `focusItem(nodeId)` — select item by ID
|
- **Public methods:**
|
||||||
- `handleFilesKeydown(e)` — delegate keyboard handling
|
- `resetToNode(nodeId)` — reset state when selected node changes
|
||||||
- `resetState()` — full reset (on node change)
|
- `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)
|
||||||
|
|
||||||
**Internal state** (owned by FilesTab, NOT accessible from App):
|
## Build & Verification
|
||||||
- `loadingFiles`, `currentFolderId`, `folderStack`, `fileItems`
|
|
||||||
- `previewItem`, `previewContent`, `previewLoading`, `previewError`
|
|
||||||
- `clipboard`, `selectedIds`, `dragIds`
|
|
||||||
- `importing`, `importSummary`, `showImportDialog`, `pendingImportPath`, `pendingImportParent`
|
|
||||||
- `showRename`, `renameId`, `renameValue`, `renameError`
|
|
||||||
- `showConfirm`, `confirmTitle`, `confirmMessage`, `confirmAction`, `cancelAction`
|
|
||||||
|
|
||||||
## State Ownership Rules
|
- `npm run build` in `frontend/` directory
|
||||||
|
- `go test ./...` from project root
|
||||||
### App.svelte MUST own:
|
- `bash scripts/build.sh gui` for full GUI binary
|
||||||
- `selectedSection`, `selectedNode`, `activeTab`
|
- Manual smoke testing via Wails dev server
|
||||||
- `systemViews`, `workspaceTree`, `enabledTemplates`
|
|
||||||
- `noteEditor` (the note being edited), `noteViewMode`
|
|
||||||
- `showFirstRun`, `showRecovery`, `showSettings`, `loading`
|
|
||||||
- `startupStatus`, `startupChecked`
|
|
||||||
- Navigation state: `navHistory`, `restoringHistory`
|
|
||||||
- Trash browser state: `trashInfo`, `trashCount`, `trashSelectedIds`, `trashFolderId`, `trashFolderStack`
|
|
||||||
- Trash preview: `trashPreviewItem`, `trashPreviewContent`, `trashPreviewLoading`, `trashPreviewError`
|
|
||||||
- Journal state: `journalRows`, `journalSummary`, filters
|
|
||||||
- Worklog modal state: `showWorklogModal`, `wlModal*`
|
|
||||||
- Inbox: `inboxNodes`, `localInboxNodes`, capture state
|
|
||||||
- Links: `links`
|
|
||||||
- Sync: `syncStatus`
|
|
||||||
- `error` (global error banner)
|
|
||||||
- `notes` (loaded by `loadTabData`, passed to NotesTab and OverviewTab as prop)
|
|
||||||
- `worklog` (loaded by `loadTabData`, passed to OverviewTab as prop)
|
|
||||||
|
|
||||||
### App.svelte must NOT directly reference:
|
|
||||||
- Files tab internal state (all in FilesTab)
|
|
||||||
- Notes tab create form state (showCreateNote, newNoteTitle — in NotesTab)
|
|
||||||
- Any `fileItems`, `selectedIds`, `currentFolderId`, `folderStack`, etc.
|
|
||||||
|
|
||||||
## Services (Wails API)
|
|
||||||
|
|
||||||
All backend calls go through the `wailsCall()` helper in App.svelte:
|
|
||||||
|
|
||||||
```js
|
|
||||||
function wailsCall(method, ...args) {
|
|
||||||
// Returns Promise, rejects with 'Wails not connected: <method>' if backend unavailable
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Key backend methods:
|
|
||||||
- **Startup**: `GetStartupStatus`, `VerstakVersion`, `ListSystemViewsWithPlugins`, `ListWorkspaceTree`, `ListEnabledTemplates`
|
|
||||||
- **Nodes**: `GetNodeDetail`, `ListItems`, `ListWorkspaceChildren`, `CreateNodeFromTemplate`, `DeleteNode`, `MoveNode`, `RenameNode`
|
|
||||||
- **Notes**: `ListNotes`, `CreateNote`, `ReadNote`, `SaveNote`, `DeleteNote`, `RenameNote`
|
|
||||||
- **Files**: `ListFiles`, `DeleteFileOrFolder`, `CreateEmptyFile`, `DuplicateNode`, `OpenFile`, `OpenFolder`, `GetFileBase64`, `ReadFileText`, `PreviewImport`, `AddPathCopy`, `AddPathLink`
|
|
||||||
- **Worklog**: `ListWorklog`, `CreateWorklogFull`, `UpdateWorklogEntry`, `DeleteWorklogEntry`, `AcceptSuggestionFull/With`, `GetSuggestions`
|
|
||||||
- **Inbox**: `ListInboxNodes`, `Capture*WithContext`, `DeleteInboxNode`, `ResolveInboxNode`
|
|
||||||
- **Sync**: `SyncStatus`, `SyncNow`, `TrashCount`, `ListTrash`
|
|
||||||
- **Logging**: `FrontendLog(level, message, stack)`, `LogStartupStep(step, success, detail)`
|
|
||||||
|
|
||||||
## Logging & Diagnostics
|
|
||||||
|
|
||||||
### Frontend errors
|
|
||||||
- Global `window.addEventListener('error', ...)` catches uncaught exceptions
|
|
||||||
- Global `window.addEventListener('unhandledrejection', ...)` catches broken promises
|
|
||||||
- Both log to `console.error` and forward to `FrontendLog()` backend binding
|
|
||||||
- Fatal mount error: renders error message inline in `#app` div (blank window becomes diagnostic)
|
|
||||||
|
|
||||||
### Backend logs
|
|
||||||
- Application log file: `~/.local/state/verstak/logs/verstak.log` (Linux)
|
|
||||||
- Fallback: `~/.config/verstak/logs/verstak.log`
|
|
||||||
- Format: `[timestamp] [level] message`
|
|
||||||
- Go `log.Printf()` output goes to stderr (visible in dev console)
|
|
||||||
|
|
||||||
### Vault debug log
|
|
||||||
- `<vault>/.verstak/debug.log` — written by `WriteDebugLog` binding (existing feature)
|
|
||||||
|
|
||||||
## Refactor Rules
|
|
||||||
|
|
||||||
### After each extraction step:
|
|
||||||
1. `grep` App.svelte for state that moved to child — must be zero hits or documented exceptions
|
|
||||||
2. `npm run build` — must pass
|
|
||||||
3. `go test ./...` — must pass
|
|
||||||
4. Manual GUI check:
|
|
||||||
- Window is not blank/white
|
|
||||||
- Sidebar shows (system views + workspace tree)
|
|
||||||
- No infinite "Загрузка..." on welcome screen
|
|
||||||
- Clicking system sections works
|
|
||||||
- Opening a case shows tabs
|
|
||||||
|
|
||||||
### Forbidden patterns:
|
|
||||||
- Child component state referenced directly in App.svelte after extraction
|
|
||||||
- `lang="ts"` in Svelte files (svelte-preprocess not installed)
|
|
||||||
- `[]string` passed directly to Wails bindings (must JSON-serialize)
|
|
||||||
- Modal without `role="dialog" aria-modal="true"` and Escape handler
|
|
||||||
- Buttons without `type="button"` in forms
|
|
||||||
- `resize: none` on textareas that should be resizable (use `resize: vertical`)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Blank window, no errors in terminal
|
|
||||||
1. Check browser DevTools console (F12 on Windows/Linux, Cmd+Alt+I on macOS)
|
|
||||||
2. In Wails dev mode: right-click → Inspect → Console
|
|
||||||
3. Look for `[Frontend Error]` messages
|
|
||||||
4. Check application log: `~/.local/state/verstak/logs/verstak.log`
|
|
||||||
|
|
||||||
### Stuck on "Загрузка..."
|
|
||||||
- `GetStartupStatus` likely failed or returned unexpected status
|
|
||||||
- Check network calls in DevTools Network tab
|
|
||||||
- Check backend log for startup errors
|
|
||||||
|
|
||||||
### Wails method not found
|
|
||||||
- Ensure method is registered in `bindings*.go` files
|
|
||||||
- Ensure method is exported (capitalized name)
|
|
||||||
- Ensure `wailsCall()` helper is used (not direct window access)
|
|
||||||
|
|
||||||
### Child component state moved but App still references old variable
|
|
||||||
- This causes a silent `undefined` reference or runtime crash
|
|
||||||
- Run `grep -n 'oldStateName' App.svelte` after each extraction
|
|
||||||
- Use `ref?.method?.()` for all public API calls on child components
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# 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
|
||||||
+222
-155
@@ -18,10 +18,15 @@
|
|||||||
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 NotesTab from './lib/components/notes/NotesTab.svelte'
|
import ErrorBanner from './lib/components/ErrorBanner.svelte'
|
||||||
|
import CaptureDropOverlay from './lib/components/CaptureDropOverlay.svelte'
|
||||||
import OverviewTab from './lib/components/OverviewTab.svelte'
|
import OverviewTab from './lib/components/OverviewTab.svelte'
|
||||||
|
import NotesTab from './lib/components/notes/NotesTab.svelte'
|
||||||
import FilesTab from './lib/components/files/FilesTab.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) {
|
||||||
try {
|
try {
|
||||||
@@ -79,10 +84,22 @@
|
|||||||
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
|
||||||
let filesTabRef = undefined; // bind:this ref for FilesTab
|
|
||||||
let noteViewMode = 'edit'
|
let noteViewMode = 'edit'
|
||||||
let showLinkModal = false
|
let showLinkModal = false
|
||||||
let linkModalLabel = ''
|
let linkModalLabel = ''
|
||||||
@@ -146,6 +163,8 @@
|
|||||||
let createInNode = null
|
let createInNode = null
|
||||||
let createWithTemplate = null
|
let createWithTemplate = null
|
||||||
let contextMenu = { visible: false, x: 0, y: 0, node: null }
|
let contextMenu = { visible: false, x: 0, y: 0, node: null }
|
||||||
|
let showCreateNote = false
|
||||||
|
let newNoteTitle = ''
|
||||||
let showCreateAction = false
|
let showCreateAction = false
|
||||||
let newActionTitle = ''
|
let newActionTitle = ''
|
||||||
let newActionKind = 'open_url'
|
let newActionKind = 'open_url'
|
||||||
@@ -160,21 +179,6 @@
|
|||||||
{ id: 'launch_app', label: t('action.launchApp') },
|
{ id: 'launch_app', label: t('action.launchApp') },
|
||||||
]
|
]
|
||||||
let loading = true
|
let loading = true
|
||||||
let treeItems = []
|
|
||||||
let expanded = {}
|
|
||||||
let childrenMap = {}
|
|
||||||
|
|
||||||
let trashPreviewItem = null
|
|
||||||
let trashPreviewContent = ''
|
|
||||||
let trashPreviewLoading = false
|
|
||||||
let trashPreviewError = ''
|
|
||||||
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 = ''
|
||||||
@@ -292,12 +296,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 (trashPreviewItem) { closeTrashPreview(); 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 (showCreateAction) { cancelCreateAction(); return true }
|
if (showCreateAction) { cancelCreateAction(); return true }
|
||||||
|
if (showCreateNote) { cancelCreateNote(); return true }
|
||||||
if (showCreateNode) { cancelCreateNode(); return true }
|
if (showCreateNode) { cancelCreateNode(); return true }
|
||||||
if (contextMenu.visible) { closeContextMenu(); return true }
|
if (contextMenu.visible) { closeContextMenu(); return true }
|
||||||
if (noteEditor) { closeNoteEditor(); return true }
|
if (noteEditor) { closeNoteEditor(); return true }
|
||||||
@@ -314,9 +318,6 @@
|
|||||||
}
|
}
|
||||||
await selectNode(node)
|
await selectNode(node)
|
||||||
activeTab = snapshot.tab || 'overview'
|
activeTab = snapshot.tab || 'overview'
|
||||||
if (activeTab === 'files') {
|
|
||||||
filesTabRef?.resetToNode?.(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)) {
|
||||||
@@ -361,7 +362,6 @@
|
|||||||
if (activeTab === tabId) return
|
if (activeTab === tabId) return
|
||||||
rememberNavigation()
|
rememberNavigation()
|
||||||
activeTab = tabId
|
activeTab = tabId
|
||||||
if (tabId === 'files' && selectedNode) filesTabRef?.resetToNode?.(selectedNode.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Lifecycle =====
|
// ===== Lifecycle =====
|
||||||
@@ -512,8 +512,10 @@
|
|||||||
resetTrashBrowser()
|
resetTrashBrowser()
|
||||||
noteEditor = null
|
noteEditor = null
|
||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
|
showCreateNote = false
|
||||||
error = ''
|
error = ''
|
||||||
caseActivity = []
|
caseActivity = []
|
||||||
|
if (filesTabRef) filesTabRef.resetToNode(node.id)
|
||||||
await loadTabData(node.id)
|
await loadTabData(node.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,63 +533,7 @@
|
|||||||
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTree(nodeID) {
|
// ===== Keyboard =====
|
||||||
try {
|
|
||||||
treeItems = await wailsCall('ListItems', nodeID) || []
|
|
||||||
} catch (e) {
|
|
||||||
treeItems = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Node rename (for tree context menu) =====
|
|
||||||
function openNodeRename(id, currentName) {
|
|
||||||
renameId = id
|
|
||||||
renameValue = currentName
|
|
||||||
renameError = ''
|
|
||||||
showRename = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitNodeRename() {
|
|
||||||
const name = renameValue.trim()
|
|
||||||
if (!name) { renameError = t('rename.emptyError'); return }
|
|
||||||
try {
|
|
||||||
await wailsCall('ValidateName', name)
|
|
||||||
} catch (e) {
|
|
||||||
renameError = t('rename.invalidError')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showRename = false
|
|
||||||
const id = renameId
|
|
||||||
renameId = ''
|
|
||||||
try {
|
|
||||||
await wailsCall('RenameNode', id, name)
|
|
||||||
if (selectedNode && selectedNode.id === id) {
|
|
||||||
selectedNode = { ...selectedNode, title: name }
|
|
||||||
}
|
|
||||||
await reloadTreePreservingExpanded()
|
|
||||||
} catch (e) {
|
|
||||||
error = String(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelNodeRename() {
|
|
||||||
showRename = false
|
|
||||||
renameId = ''
|
|
||||||
renameValue = ''
|
|
||||||
renameError = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function onNodeRenameKeydown(e) {
|
|
||||||
if (e.key === 'Enter') submitNodeRename()
|
|
||||||
else renameError = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Trash preview =====
|
|
||||||
function closeTrashPreview() {
|
|
||||||
trashPreviewItem = null
|
|
||||||
trashPreviewContent = ''
|
|
||||||
trashPreviewError = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEditableTarget(target) {
|
function isEditableTarget(target) {
|
||||||
if (!target || !(target instanceof Element)) return false
|
if (!target || !(target instanceof Element)) return false
|
||||||
@@ -608,14 +554,71 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab === 'files' && filesTabRef) {
|
if (activeTab !== 'files') return
|
||||||
return filesTabRef.handleFilesKeydown(e)
|
|
||||||
|
if (filesTabRef) {
|
||||||
|
filesTabRef.filesHandleKeydown(e)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Rename modal =====
|
// ===== Rename modal =====
|
||||||
|
|
||||||
// ===== Confirm modal =====
|
function openRename(id, currentName) {
|
||||||
|
renameId = id
|
||||||
|
renameValue = currentName
|
||||||
|
renameError = ''
|
||||||
|
showRename = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRenameForSelection() {
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||||
|
if (item) {
|
||||||
|
openRename(item.id, item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRename() {
|
||||||
|
const name = renameValue.trim()
|
||||||
|
if (!name) { renameError = t('rename.emptyError'); return }
|
||||||
|
try {
|
||||||
|
await wailsCall('ValidateName', name)
|
||||||
|
} catch (e) {
|
||||||
|
renameError = t('rename.invalidError')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showRename = false
|
||||||
|
const id = renameId
|
||||||
|
renameId = ''
|
||||||
|
try {
|
||||||
|
await wailsCall('RenameNode', id, name)
|
||||||
|
if (selectedNode && selectedNode.id === id) {
|
||||||
|
selectedNode = { ...selectedNode, title: name }
|
||||||
|
}
|
||||||
|
await reloadTreePreservingExpanded()
|
||||||
|
if (currentFolderId) {
|
||||||
|
await loadFolder(currentFolderId)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
showRename = false
|
||||||
|
renameId = ''
|
||||||
|
renameValue = ''
|
||||||
|
renameError = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRenameKeydown(e) {
|
||||||
|
if (e.key === 'Enter') submitRename()
|
||||||
|
else renameError = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Confirm modal =====
|
||||||
|
|
||||||
function openConfirm(opts) {
|
function openConfirm(opts) {
|
||||||
confirmTitle = opts.title || t('common.confirm')
|
confirmTitle = opts.title || t('common.confirm')
|
||||||
@@ -767,7 +770,7 @@
|
|||||||
|
|
||||||
// ===== Node operations from context menu =====
|
// ===== Node operations from context menu =====
|
||||||
function openRenameForNode(node) {
|
function openRenameForNode(node) {
|
||||||
openNodeRename(node.id, node.title)
|
openRename(node.id, node.title)
|
||||||
closeContextMenu()
|
closeContextMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,8 +843,12 @@
|
|||||||
selectedSection = ''
|
selectedSection = ''
|
||||||
selectedNode = item
|
selectedNode = item
|
||||||
activeTab = 'files'
|
activeTab = 'files'
|
||||||
|
folderStack = []
|
||||||
|
currentFolderId = null
|
||||||
|
selectedIds = []
|
||||||
|
previewItem = null
|
||||||
await loadTabData(item.id)
|
await loadTabData(item.id)
|
||||||
filesTabRef?.resetToNode?.(item.id)
|
await loadFolder(item.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,8 +858,7 @@
|
|||||||
if (!record) throw new Error('file record not found')
|
if (!record) throw new Error('file record not found')
|
||||||
const preview = fileRecordToPreviewItem(item, record)
|
const preview = fileRecordToPreviewItem(item, record)
|
||||||
if (canPreviewFile(preview)) {
|
if (canPreviewFile(preview)) {
|
||||||
activeTab = 'files'
|
await openPreview(preview)
|
||||||
filesTabRef?.openFileById?.(item.id)
|
|
||||||
} else {
|
} else {
|
||||||
await wailsCall('OpenFile', preview.fileId)
|
await wailsCall('OpenFile', preview.fileId)
|
||||||
}
|
}
|
||||||
@@ -913,17 +919,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== Notes =====
|
// ===== Notes =====
|
||||||
// Notes create/delete/rename are handled by NotesTab component via events.
|
function openCreateNote() { showCreateNote = true; newNoteTitle = '' }
|
||||||
// App.svelte keeps note editor lifecycle (doOpenNote, saveCurrentNote, etc.)
|
function cancelCreateNote() { showCreateNote = false; newNoteTitle = '' }
|
||||||
|
async function submitCreateNote() {
|
||||||
async function _handleSubmitCreateNote(title) {
|
if (!newNoteTitle.trim() || !selectedNode) return
|
||||||
if (!title.trim() || !selectedNode) return
|
|
||||||
try {
|
try {
|
||||||
const note = await wailsCall('CreateNote', selectedNode.id, title.trim())
|
const note = await wailsCall('CreateNote', selectedNode.id, newNoteTitle.trim())
|
||||||
notes = [...notes, (note && note.id) ? note : { id: Date.now().toString(), title: title.trim(), createdAt: new Date().toISOString() }]
|
notes = [...notes, (note && note.id) ? note : { id: Date.now().toString(), title: newNoteTitle.trim(), createdAt: new Date().toISOString() }]
|
||||||
|
showCreateNote = false
|
||||||
|
newNoteTitle = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const newNote = { id: Date.now().toString(), title: title.trim(), createdAt: new Date().toISOString() }
|
// Fallback: create note locally
|
||||||
|
const newNote = { id: Date.now().toString(), title: newNoteTitle.trim(), createdAt: new Date().toISOString() }
|
||||||
notes = [...notes, newNote]
|
notes = [...notes, newNote]
|
||||||
|
showCreateNote = false
|
||||||
|
newNoteTitle = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,8 +1167,14 @@
|
|||||||
await selectNode(parent)
|
await selectNode(parent)
|
||||||
}
|
}
|
||||||
setActiveTab('files')
|
setActiveTab('files')
|
||||||
filesTabRef?.resetToNode?.(parentId)
|
await loadFolder(parentId)
|
||||||
filesTabRef?.openFileById?.(id)
|
// Find the file in the loaded fileItems and open preview
|
||||||
|
const fileItem = fileItems.find(f => f.id === id)
|
||||||
|
if (fileItem) {
|
||||||
|
await openPreview(fileItem)
|
||||||
|
} else {
|
||||||
|
showVerstakToastMessage(t('note.internal.fileFound', { title: node.title }))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showVerstakToastMessage(t('note.internal.fileFound', { title: node.title }))
|
showVerstakToastMessage(t('note.internal.fileFound', { title: node.title }))
|
||||||
}
|
}
|
||||||
@@ -1328,22 +1344,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openTrashFilePreview(node) {
|
async function openTrashFilePreview(node) {
|
||||||
trashPreviewItem = { name: node.title, type: 'file', mime: 'text/plain', size: 0, fileId: node.id }
|
previewItem = { name: node.title, type: 'file', mime: 'text/plain', size: 0, fileId: node.id }
|
||||||
trashPreviewContent = ''
|
previewContent = ''
|
||||||
trashPreviewError = ''
|
previewError = ''
|
||||||
trashPreviewLoading = true
|
previewLoading = true
|
||||||
try {
|
try {
|
||||||
if (node.trashFsPath) {
|
if (node.trashFsPath) {
|
||||||
trashPreviewContent = await wailsCall('ReadTrashFile', node.trashFsPath) || ''
|
previewContent = await wailsCall('ReadTrashFile', node.trashFsPath) || ''
|
||||||
} else {
|
} else {
|
||||||
trashPreviewContent = await wailsCall('ReadTrashFileContent', node.id) || ''
|
previewContent = await wailsCall('ReadTrashFileContent', node.id) || ''
|
||||||
}
|
}
|
||||||
const ext = (node.title || '').split('.').pop().toLowerCase()
|
const ext = (node.title || '').split('.').pop().toLowerCase()
|
||||||
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) {
|
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) {
|
||||||
trashPreviewContent = 'data:image/' + (ext === 'svg' ? 'svg+xml' : ext) + ';base64,' + btoa(previewContent)
|
previewContent = 'data:image/' + (ext === 'svg' ? 'svg+xml' : ext) + ';base64,' + btoa(previewContent)
|
||||||
}
|
}
|
||||||
} catch (e) { trashPreviewError = String(e) }
|
} catch (e) { previewError = String(e) }
|
||||||
trashPreviewLoading = false
|
previewLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTrashSelection(id) {
|
function toggleTrashSelection(id) {
|
||||||
@@ -1622,8 +1638,16 @@
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Files =====
|
// ===== Drag-and-drop =====
|
||||||
// ===== Drag-and-drop =====
|
async function openSelectedFile(fileID) {
|
||||||
|
try {
|
||||||
|
await wailsCall('OpenFile', fileID)
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Drag-and-drop =====
|
||||||
async function onFilesDropped(paths) {
|
async function onFilesDropped(paths) {
|
||||||
try {
|
try {
|
||||||
if (!paths || paths.length === 0) return
|
if (!paths || paths.length === 0) return
|
||||||
@@ -1904,6 +1928,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function hasExternalCaptureData(dataTransfer) {
|
function hasExternalCaptureData(dataTransfer) {
|
||||||
|
if (dragIds.length > 0) return false
|
||||||
const types = Array.from(dataTransfer?.types || [])
|
const types = Array.from(dataTransfer?.types || [])
|
||||||
return types.includes('Files') ||
|
return types.includes('Files') ||
|
||||||
types.includes('text/uri-list') ||
|
types.includes('text/uri-list') ||
|
||||||
@@ -2230,17 +2255,20 @@
|
|||||||
try {
|
try {
|
||||||
const detail = await wailsCall('GetNodeDetail', target.targetId)
|
const detail = await wailsCall('GetNodeDetail', target.targetId)
|
||||||
if (detail && detail.parent_id) {
|
if (detail && detail.parent_id) {
|
||||||
filesTabRef?.resetToNode?.(detail.parent_id)
|
await loadFolder(detail.parent_id)
|
||||||
filesTabRef?.openFileById?.(target.targetId)
|
const fileItem = fileItems.find(f => f.id === target.targetId)
|
||||||
|
if (fileItem && fileItem.type === 'file' && canPreviewFile(fileItem)) {
|
||||||
|
setTimeout(() => openPreview(fileItem), 150)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// No parent — item sits at the root level
|
// No parent — item sits at the root level
|
||||||
filesTabRef?.resetToNode?.(targetNode)
|
await loadFolder(targetNode)
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
filesTabRef?.resetToNode?.(targetNode)
|
await loadFolder(targetNode)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
filesTabRef?.resetToNode?.(targetNode)
|
await loadFolder(targetNode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2291,15 +2319,18 @@
|
|||||||
if (parent) {
|
if (parent) {
|
||||||
await selectNode(parent)
|
await selectNode(parent)
|
||||||
setActiveTab('files')
|
setActiveTab('files')
|
||||||
filesTabRef?.resetToNode?.(parent.id)
|
await loadFolder(parent.id)
|
||||||
filesTabRef?.openFileById?.(detail.id)
|
const fileItem = fileItems.find(item => item.id === detail.id)
|
||||||
|
if (fileItem && canPreviewFile(fileItem)) {
|
||||||
|
await openPreview(fileItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (result.type === 'folder') {
|
if (result.type === 'folder') {
|
||||||
await selectNode(detail)
|
await selectNode(detail)
|
||||||
setActiveTab('files')
|
setActiveTab('files')
|
||||||
filesTabRef?.resetToNode?.(detail.id)
|
await loadFolder(detail.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await selectNode(detail)
|
await selectNode(detail)
|
||||||
@@ -2394,19 +2425,13 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#if showFirstRun}
|
{#if showFirstRun}
|
||||||
<FirstRun onComplete={onFirstRunComplete} />
|
<FirstRun onComplete={onFirstRunComplete} />
|
||||||
{:else if showRecovery}
|
{:else if showRecovery}
|
||||||
<VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} />
|
<VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="app">
|
<div class="app">
|
||||||
{#if captureDropActive}
|
<CaptureDropOverlay show={captureDropActive} label={captureDropLabel} />
|
||||||
<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">
|
||||||
@@ -2497,16 +2522,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
|
|
||||||
{#if error}
|
<ErrorBanner {error} onDismiss={() => 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 -->
|
||||||
@@ -2562,35 +2578,40 @@
|
|||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
{#if activeTab === 'overview'}
|
{#if activeTab === 'overview'}
|
||||||
<OverviewTab
|
<OverviewTab
|
||||||
{selectedNode}
|
node={selectedNode}
|
||||||
{notes}
|
{notes}
|
||||||
{worklog}
|
{worklog}
|
||||||
{formatDate}
|
|
||||||
{nodeKindLabel}
|
{nodeKindLabel}
|
||||||
on:createNote={() => { setActiveTab('notes'); }}
|
{formatDate}
|
||||||
on:addFile={() => { setActiveTab('files'); if (filesTabRef) filesTabRef.addFile(); }}
|
on:goTab={(e) => setActiveTab(e.detail)}
|
||||||
on:createAction={openCreateAction}
|
|
||||||
on:openNote={(e) => openNote(e.detail.note)}
|
on:openNote={(e) => openNote(e.detail.note)}
|
||||||
|
on:createNote={() => { setActiveTab('notes'); openCreateNote() }}
|
||||||
|
on:addFile={() => { setActiveTab('files'); addFile() }}
|
||||||
|
on:createAction={openCreateAction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{:else if activeTab === 'notes'}
|
{:else if activeTab === 'notes'}
|
||||||
<NotesTab
|
<NotesTab
|
||||||
{notes}
|
{notes}
|
||||||
|
showCreateNote={showCreateNote}
|
||||||
{formatDate}
|
{formatDate}
|
||||||
on:submitCreateNote={(e) => _handleSubmitCreateNote(e.detail.title)}
|
on:createNote={openCreateNote}
|
||||||
|
on:submitCreateNote={(e) => { newNoteTitle = e.detail.title; submitCreateNote() }}
|
||||||
|
on:cancelCreateNote={cancelCreateNote}
|
||||||
on:openNote={(e) => openNote(e.detail.note)}
|
on:openNote={(e) => openNote(e.detail.note)}
|
||||||
on:startRename={(e) => startRenameNote(e.detail.noteId, e.detail.currentTitle)}
|
on:startRename={(e) => startRenameNote(e.detail.note.id, e.detail.note.title)}
|
||||||
on:deleteNote={(e) => deleteNote(e.detail.note)}
|
on:delete={(e) => deleteNote(e.detail.note)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{:else if activeTab === 'files'}
|
{:else if activeTab === 'files'}
|
||||||
<FilesTab
|
<FilesTab
|
||||||
bind:this={filesTabRef}
|
bind:this={filesTabRef}
|
||||||
{selectedNode}
|
{selectedNode}
|
||||||
{wailsCall}
|
on:openNote={(e) => openNote(e.detail.note)}
|
||||||
on:openNote={(e) => openNote(e.detail)}
|
|
||||||
on:refreshParent={(e) => refreshParentNode(e.detail.nodeId)}
|
on:refreshParent={(e) => refreshParentNode(e.detail.nodeId)}
|
||||||
on:error={(e) => error = e.detail.message}
|
on:error={(e) => { error = e.detail.message }}
|
||||||
|
on:rename={(e) => openRename(e.detail.id, e.detail.name)}
|
||||||
|
on:confirm={(e) => openConfirm(e.detail)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{:else if activeTab === 'inbox'}
|
{:else if activeTab === 'inbox'}
|
||||||
@@ -3458,21 +3479,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showRename}
|
{#if showRename}
|
||||||
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelNodeRename} on:keydown={onKeyActivate(cancelNodeRename)}>
|
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={onKeyActivate(cancelRename)}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>{t('rename.title')}</h3>
|
<h3>{t('rename.title')}</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><span class="label-text">{t('common.newName')}</span>
|
<label><span class="label-text">{t('common.newName')}</span>
|
||||||
<input type="text" bind:value={renameValue}
|
<input type="text" bind:value={renameValue}
|
||||||
on:keydown={onNodeRenameKeydown} />
|
on:keydown={onRenameKeydown} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{#if renameError}
|
{#if renameError}
|
||||||
<div class="rename-error">{renameError}</div>
|
<div class="rename-error">{renameError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" on:click={submitNodeRename}>{t('common.rename')}</button>
|
<button class="btn btn-primary" on:click={submitRename}>{t('common.rename')}</button>
|
||||||
<button class="btn" on:click={cancelNodeRename}>{t('common.cancel')}</button>
|
<button class="btn" on:click={cancelRename}>{t('common.cancel')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3553,13 +3574,13 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if trashPreviewItem}
|
{#if previewItem}
|
||||||
<FilePreviewModal
|
<FilePreviewModal
|
||||||
item={trashPreviewItem}
|
item={previewItem}
|
||||||
content={trashPreviewContent}
|
content={previewContent}
|
||||||
loading={trashPreviewLoading}
|
loading={previewLoading}
|
||||||
error={trashPreviewError}
|
error={previewError}
|
||||||
on:close={closeTrashPreview}
|
on:close={closePreview}
|
||||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -3571,7 +3592,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
.app { display: flex; width: 100vw; height: 100vh; overflow: hidden; background: #13131f; color: #e4e4ef; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; }
|
.app { display: flex; width: 100vw; height: 100vh; overflow: hidden; background: #13131f; color: #e4e4ef; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; }
|
||||||
@@ -3646,6 +3666,15 @@
|
|||||||
.note-rename-input { flex: 1; padding: 6px 10px; border: 1px solid #2a2a3c; border-radius: 4px; background: #13131f; color: #e4e4ef; font-size: 14px; font-family: inherit; outline: none; }
|
.note-rename-input { flex: 1; padding: 6px 10px; border: 1px solid #2a2a3c; border-radius: 4px; background: #13131f; color: #e4e4ef; font-size: 14px; font-family: inherit; outline: none; }
|
||||||
.note-rename-input:focus { border-color: #818cf8; }
|
.note-rename-input:focus { border-color: #818cf8; }
|
||||||
|
|
||||||
|
/* Note card actions */
|
||||||
|
.note-card { position: relative; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
/* Form groups in modals */
|
/* Form groups in modals */
|
||||||
.form-group { margin-bottom: 14px; }
|
.form-group { margin-bottom: 14px; }
|
||||||
.form-group label { display: block; font-size: 12px; color: #888; margin-bottom: 4px; }
|
.form-group label { display: block; font-size: 12px; color: #888; margin-bottom: 4px; }
|
||||||
@@ -3653,6 +3682,36 @@
|
|||||||
.form-group input:focus { border-color: #818cf8; }
|
.form-group input:focus { border-color: #818cf8; }
|
||||||
.note-textarea { flex: 1; width: 100%; border: none; outline: none; background: #13131f; color: #e4e4ef; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; padding: 24px; resize: none; }
|
.note-textarea { flex: 1; width: 100%; border: none; outline: none; background: #13131f; color: #e4e4ef; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; padding: 24px; resize: none; }
|
||||||
|
|
||||||
|
/* Overview */
|
||||||
|
.overview { padding: 24px; }
|
||||||
|
.overview h2 { font-size: 24px; margin-bottom: 16px; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||||
|
.meta-item { background: #1a1a28; padding: 12px 16px; border-radius: 8px; }
|
||||||
|
.meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
|
||||||
|
.quick-actions { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||||
|
.qa-btn { padding: 10px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.qa-btn:hover { background: #222233; }
|
||||||
|
.qa-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.recent-section { margin-bottom: 24px; }
|
||||||
|
.recent-section h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 8px; }
|
||||||
|
.recent-note { padding: 8px 12px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; }
|
||||||
|
.recent-note:hover { background: #1a1a28; }
|
||||||
|
.recent-date { font-size: 11px; color: #555; }
|
||||||
|
.recent-entry { padding: 6px 0; font-size: 13px; color: #888; border-bottom: 1px solid #1a1a28; }
|
||||||
|
|
||||||
|
/* Notes tab */
|
||||||
|
.notes-tab { padding: 24px; }
|
||||||
|
.tab-toolbar { margin-bottom: 16px; }
|
||||||
|
.create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
|
||||||
|
.create-form input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 8px; }
|
||||||
|
.create-form input:focus { outline: none; border-color: #6366f1; }
|
||||||
|
.form-actions { display: flex; gap: 8px; }
|
||||||
|
.notes-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||||
|
.note-card { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 16px; cursor: pointer; }
|
||||||
|
.note-card:hover { border-color: #3a3a5c; }
|
||||||
|
.note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
|
||||||
|
.note-card-date { font-size: 11px; color: #555; }
|
||||||
|
|
||||||
/* Worklog tab */
|
/* Worklog tab */
|
||||||
.worklog-tab { padding: 24px; }
|
.worklog-tab { padding: 24px; }
|
||||||
.worklog-toolbar { margin-bottom: 16px; }
|
.worklog-toolbar { margin-bottom: 16px; }
|
||||||
@@ -3918,9 +3977,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Files tab */
|
/* Files tab */
|
||||||
|
.files-tab { padding: 20px; }
|
||||||
|
.files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
|
||||||
|
.file-list { display: flex; flex-direction: column; }
|
||||||
|
.back-btn { margin-bottom: 4px; display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
|
||||||
/* Import summary */
|
/* Import summary */
|
||||||
|
.import-summary { margin-bottom: 16px; }
|
||||||
|
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
||||||
|
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
||||||
|
|
||||||
|
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
|
||||||
|
|
||||||
/* Template cards */
|
/* Template cards */
|
||||||
.template-cards { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
|
.template-cards { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
|
||||||
|
|||||||
@@ -88,14 +88,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMenu(e) {
|
function toggleMenu() {
|
||||||
if (e) {
|
|
||||||
e.stopPropagation()
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
menuX = Math.min(rect.right, window.innerWidth - 240)
|
|
||||||
menuY = Math.min(rect.bottom, window.innerHeight - 320)
|
|
||||||
console.log('[FileTreeRow] menu source=button x=' + menuX + ' y=' + menuY)
|
|
||||||
}
|
|
||||||
menuOpen = !menuOpen
|
menuOpen = !menuOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +112,6 @@
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
menuX = Math.min(e.clientX, window.innerWidth - 240)
|
menuX = Math.min(e.clientX, window.innerWidth - 240)
|
||||||
menuY = Math.min(e.clientY, window.innerHeight - 320)
|
menuY = Math.min(e.clientY, window.innerHeight - 320)
|
||||||
console.log('[FileTreeRow] menu source=contextmenu x=' + menuX + ' y=' + menuY)
|
|
||||||
menuOpen = true
|
menuOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
|
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
|
||||||
import FileIcon from './FileIcon.svelte'
|
import FileIcon from './FileIcon.svelte'
|
||||||
import MarkdownPreview from './components/notes/MarkdownPreview.svelte'
|
|
||||||
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
|
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
|
||||||
import { t } from './i18n'
|
import { t } from './i18n'
|
||||||
|
|
||||||
@@ -14,8 +13,7 @@
|
|||||||
|
|
||||||
const kind = getFileKind(item)
|
const kind = getFileKind(item)
|
||||||
$: showImage = isImageFile(item) && content && content.startsWith('data:')
|
$: showImage = isImageFile(item) && content && content.startsWith('data:')
|
||||||
$: showMarkdown = isMarkdownFile(item) && content
|
$: showText = isTextFile(item) || isMarkdownFile(item)
|
||||||
$: showText = (isTextFile(item) || isMarkdownFile(item)) && content && !showMarkdown
|
|
||||||
$: showPdf = isPdfFile(item)
|
$: showPdf = isPdfFile(item)
|
||||||
|
|
||||||
function handleKeydown(e) {
|
function handleKeydown(e) {
|
||||||
@@ -46,6 +44,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||||
<div class="preview-actions">
|
<div class="preview-actions">
|
||||||
|
<button class="action-btn" on:click={handleOpenExternal} title={t('file.openExternal')} aria-label={t('file.openExternal')}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="action-btn action-btn-close" on:click={() => dispatch('close')} title="Close" aria-label="Close preview">
|
<button class="action-btn action-btn-close" on:click={() => dispatch('close')} title="Close" aria-label="Close preview">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
@@ -66,11 +71,7 @@
|
|||||||
<div class="preview-image-container">
|
<div class="preview-image-container">
|
||||||
<img src={content} alt={item.name} class="preview-image"/>
|
<img src={content} alt={item.name} class="preview-image"/>
|
||||||
</div>
|
</div>
|
||||||
{:else if showMarkdown}
|
{:else if showText && content}
|
||||||
<div class="preview-markdown-container">
|
|
||||||
<MarkdownPreview {content} />
|
|
||||||
</div>
|
|
||||||
{:else if showText}
|
|
||||||
<pre class="preview-text"><code>{content}</code></pre>
|
<pre class="preview-text"><code>{content}</code></pre>
|
||||||
{:else if showPdf}
|
{:else if showPdf}
|
||||||
{#if content && content.startsWith('data:')}
|
{#if content && content.startsWith('data:')}
|
||||||
@@ -90,9 +91,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<footer class="preview-footer">
|
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -262,20 +260,4 @@
|
|||||||
.btn-sm:hover {
|
.btn-sm:hover {
|
||||||
background: #222233;
|
background: #222233;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-top: 1px solid #2a2a3c;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-markdown-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<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,73 +1,74 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte'
|
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
|
import { actionIcon } from '../actionIcons'
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
export let selectedNode = null
|
|
||||||
export let notes = []
|
|
||||||
export let worklog = []
|
|
||||||
export let formatDate = (str) => ''
|
|
||||||
export let nodeKindLabel = (kind) => kind || ''
|
|
||||||
|
|
||||||
// ===== Events =====
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
function createNote() {
|
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')
|
dispatch('createNote')
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFile() {
|
function goFilesAdd() {
|
||||||
|
dispatch('goTab', 'files')
|
||||||
dispatch('addFile')
|
dispatch('addFile')
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAction() {
|
function onKeyActivate(fn) {
|
||||||
dispatch('createAction')
|
return (e) => {
|
||||||
}
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
function logTime() {
|
fn()
|
||||||
dispatch('switchTab', { tab: 'worklog' })
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNoteHandler(note) {
|
|
||||||
dispatch('openNote', { note })
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overview">
|
<div class="overview">
|
||||||
<h2>{selectedNode.title}</h2>
|
<h2 class="node-title">{node.title}</h2>
|
||||||
<div class="meta-grid">
|
<div class="meta-grid">
|
||||||
<div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{nodeKindLabel(selectedNode.type)}</span></div>
|
<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>{selectedNode.section || '—'}</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(selectedNode.createdAt)}</span></div>
|
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(node.createdAt)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<button class="qa-btn" on:click={createNote}>
|
<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>
|
<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')}
|
{t('overview.newNote')}
|
||||||
</button>
|
</button>
|
||||||
<button class="qa-btn" on:click={addFile}>
|
<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>
|
<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')}
|
{t('overview.addFile')}
|
||||||
</button>
|
</button>
|
||||||
<button class="qa-btn" on:click={createAction}>
|
<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>
|
<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')}
|
{t('overview.addAction')}
|
||||||
</button>
|
</button>
|
||||||
<button class="qa-btn" on:click={logTime}>
|
<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>
|
<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')}
|
{t('overview.logTime')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if notes.length > 0}
|
{#if notes.length > 0}
|
||||||
<div class="recent-section">
|
<div class="recent-section">
|
||||||
<h3>{t('overview.recentNotes')}</h3>
|
<h3>{t('overview.recentNotes')}</h3>
|
||||||
{#each notes.slice(0, 5) as note}
|
{#each notes.slice(0, 5) as note}
|
||||||
<div class="recent-note" role="button" tabindex="0" on:click={() => openNoteHandler(note)} on:keydown={(e) => e.key === 'Enter' && openNoteHandler(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>
|
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if worklog.length > 0}
|
{#if worklog.length > 0}
|
||||||
<div class="recent-section">
|
<div class="recent-section">
|
||||||
<h3>{t('overview.recentEntries')}</h3>
|
<h3>{t('overview.recentEntries')}</h3>
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.overview { padding: 24px; }
|
.overview { padding: 24px; }
|
||||||
.overview h2 { font-size: 24px; margin-bottom: 16px; }
|
.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-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-item { background: #1a1a28; padding: 12px 16px; border-radius: 8px; }
|
||||||
.meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
|
.meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,345 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { createEventDispatcher } from 'svelte'
|
|
||||||
import MarkdownEditor from './MarkdownEditor.svelte'
|
|
||||||
import MarkdownPreview from './MarkdownPreview.svelte'
|
|
||||||
import { isMarkdownFile } from '../../fileUtils.js'
|
|
||||||
import { t } from '../../i18n'
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
export let content = ''
|
|
||||||
export let title = ''
|
|
||||||
export let subtitle = ''
|
|
||||||
export let loading = false
|
|
||||||
export let error = ''
|
|
||||||
export let dirty = false
|
|
||||||
export let isMarkdown = false
|
|
||||||
export let readonly = false
|
|
||||||
|
|
||||||
// ===== Events =====
|
|
||||||
const dispatch = createEventDispatcher()
|
|
||||||
|
|
||||||
// ===== State =====
|
|
||||||
let viewMode = 'edit' // 'edit' | 'preview' | 'split'
|
|
||||||
let editorRef = undefined
|
|
||||||
|
|
||||||
// ===== Public API =====
|
|
||||||
export function insertText(text) {
|
|
||||||
if (editorRef) editorRef.insertText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Handlers =====
|
|
||||||
function handleContentChange(e) {
|
|
||||||
content = e.detail.content
|
|
||||||
dirty = true
|
|
||||||
dispatch('content-change', e.detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSave() {
|
|
||||||
dispatch('save', { content })
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
dispatch('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenExternal() {
|
|
||||||
dispatch('openExternal')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="editor-panel">
|
|
||||||
<header class="ep-header">
|
|
||||||
<div class="ep-title-row">
|
|
||||||
{#if title}
|
|
||||||
<span class="ep-title" title={title}>{title}</span>
|
|
||||||
{/if}
|
|
||||||
{#if subtitle}
|
|
||||||
<span class="ep-subtitle">{subtitle}</span>
|
|
||||||
{/if}
|
|
||||||
{#if isMarkdown}
|
|
||||||
<span class="ep-badge">markdown</span>
|
|
||||||
{/if}
|
|
||||||
{#if dirty}
|
|
||||||
<span class="ep-dirty" title="Unsaved changes">●</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="ep-actions">
|
|
||||||
{#if isMarkdown}
|
|
||||||
<div class="ep-mode-switcher" role="tablist" aria-label="View mode">
|
|
||||||
<button type="button" class="ep-mode-btn" class:active={viewMode === 'edit'} on:click={() => viewMode = 'edit'}>
|
|
||||||
{t('note.mode.edit')}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="ep-mode-btn" class:active={viewMode === 'preview'} on:click={() => viewMode = 'preview'}>
|
|
||||||
{t('note.mode.preview')}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="ep-mode-btn" class:active={viewMode === 'split'} on:click={() => viewMode = 'split'}>
|
|
||||||
{t('note.mode.split')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="ep-body">
|
|
||||||
{#if loading}
|
|
||||||
<div class="ep-status"><p>{t('common.loading')}</p></div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="ep-status ep-error">
|
|
||||||
<p>{error}</p>
|
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
|
|
||||||
</div>
|
|
||||||
{:else if isMarkdown && viewMode === 'edit'}
|
|
||||||
<MarkdownEditor
|
|
||||||
bind:this={editorRef}
|
|
||||||
{content}
|
|
||||||
viewMode="edit"
|
|
||||||
on:content-change={handleContentChange}
|
|
||||||
on:save={handleSave}
|
|
||||||
/>
|
|
||||||
{:else if isMarkdown && viewMode === 'preview'}
|
|
||||||
<div class="ep-preview-pane">
|
|
||||||
<MarkdownPreview {content} />
|
|
||||||
</div>
|
|
||||||
{:else if isMarkdown && viewMode === 'split'}
|
|
||||||
<div class="ep-split">
|
|
||||||
<div class="ep-split-pane ep-split-editor">
|
|
||||||
<MarkdownEditor
|
|
||||||
bind:this={editorRef}
|
|
||||||
{content}
|
|
||||||
viewMode="split"
|
|
||||||
on:content-change={handleContentChange}
|
|
||||||
on:save={handleSave}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="ep-split-pane ep-split-preview">
|
|
||||||
<MarkdownPreview {content} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Plain text / code -->
|
|
||||||
<textarea
|
|
||||||
class="ep-textarea"
|
|
||||||
bind:value={content}
|
|
||||||
on:input={() => { dirty = true; dispatch('content-change', { content }) }}
|
|
||||||
{readonly}
|
|
||||||
spellcheck="false"
|
|
||||||
></textarea>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="ep-footer">
|
|
||||||
<div class="ep-footer-left">
|
|
||||||
{#if dirty && !readonly}
|
|
||||||
<span class="ep-dirty-hint">{t('editor.unsaved')}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="ep-footer-right">
|
|
||||||
{#if !readonly}
|
|
||||||
<button class="btn btn-primary btn-sm" on:click={handleSave} disabled={!dirty}>{t('common.save')}</button>
|
|
||||||
{/if}
|
|
||||||
<button class="btn btn-sm" on:click={handleClose}>{t('common.close')}</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.editor-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
background: #13131f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid #2a2a3c;
|
|
||||||
background: #16161f;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #e4e4ef;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-subtitle {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #666;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-badge {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #2a2a3c;
|
|
||||||
color: #888;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-dirty {
|
|
||||||
color: #f59e0b;
|
|
||||||
font-size: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-mode-switcher {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-mode-btn {
|
|
||||||
padding: 3px 10px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
color: #888;
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-mode-btn:hover {
|
|
||||||
color: #ccc;
|
|
||||||
background: #1e1e30;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-mode-btn.active {
|
|
||||||
color: #e4e4ef;
|
|
||||||
background: #22223a;
|
|
||||||
border-color: #333350;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-status {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 48px 24px;
|
|
||||||
color: #888;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-error {
|
|
||||||
color: #ff8888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-preview-pane {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-split {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-split-pane {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-split-editor {
|
|
||||||
border-right: 1px solid #2a2a3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-split-preview {
|
|
||||||
background: #11111c;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-textarea {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
background: #13131f;
|
|
||||||
color: #e4e4ef;
|
|
||||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.65;
|
|
||||||
padding: 16px 20px;
|
|
||||||
resize: none;
|
|
||||||
tab-size: 2;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-textarea:read-only {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-top: 1px solid #2a2a3c;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-footer-left {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-footer-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ep-dirty-hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
||||||
</style>
|
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="markdown-body" on:click={handleClick} role="article" tabindex="0" on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(e) }}>
|
<div class="markdown-body" on:click={handleClick} role="article">
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="md-error">
|
<div class="md-error">
|
||||||
<p>⚠️ {t('note.preview.error')}</p>
|
<p>⚠️ {t('note.preview.error')}</p>
|
||||||
|
|||||||
@@ -1,62 +1,188 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte';
|
||||||
import EditorPanel from './EditorPanel.svelte'
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||||
import { t } from '../../i18n'
|
import MarkdownPreview from './MarkdownPreview.svelte';
|
||||||
|
import { t } from '../../i18n';
|
||||||
|
|
||||||
// ===== Props (note context) =====
|
export let content = '';
|
||||||
export let content = ''
|
export let viewMode = 'edit';
|
||||||
export let viewMode = 'edit'
|
export let placeholder = '';
|
||||||
export let placeholder = ''
|
|
||||||
export let noteId = ''
|
|
||||||
export let noteTitle = ''
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher();
|
||||||
let editorRef = undefined
|
let activeEditor = undefined; // bind:this ref for the visible MarkdownEditor
|
||||||
|
|
||||||
// ===== Public API =====
|
function setMode(mode) {
|
||||||
export function insertText(text) {
|
dispatch('mode-change', { mode });
|
||||||
if (editorRef) editorRef.insertText(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Handlers =====
|
|
||||||
function handleContentChange(e) {
|
function handleContentChange(e) {
|
||||||
content = e.detail.content
|
content = e.detail.content;
|
||||||
dispatch('content-change', e.detail)
|
dispatch('content-change', e.detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
dispatch('save')
|
dispatch('save');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInsertLink() {
|
function handleInsertLink() {
|
||||||
dispatch('insert-link')
|
dispatch('insert-link');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInsertInternalLink() {
|
function handleInsertInternalLink() {
|
||||||
dispatch('insert-internal-link')
|
dispatch('insert-internal-link');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVerstakLink(e) {
|
function handleVerstakLink(e) {
|
||||||
dispatch('verstak-link', e.detail)
|
dispatch('verstak-link', e.detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleModeChange(e) {
|
// ─── Public API ──────────────────────────────────────────────────
|
||||||
viewMode = e.detail.mode
|
export function insertText(text) {
|
||||||
dispatch('mode-change', e.detail)
|
if (activeEditor && typeof activeEditor.insertText === 'function') {
|
||||||
|
activeEditor.insertText(text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditorPanel
|
<div class="note-editor-panel" class:mode-edit={viewMode === 'edit'} class:mode-preview={viewMode === 'preview'} class:mode-split={viewMode === 'split'}>
|
||||||
bind:this={editorRef}
|
<!-- Mode switcher -->
|
||||||
{content}
|
<div class="mode-switcher" role="tablist" aria-label="Note view mode">
|
||||||
title={noteTitle}
|
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'edit'} class:active={viewMode === 'edit'} on:click={() => setMode('edit')}>
|
||||||
isMarkdown={true}
|
{t('note.mode.edit')}
|
||||||
{viewMode}
|
</button>
|
||||||
on:content-change={handleContentChange}
|
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'preview'} class:active={viewMode === 'preview'} on:click={() => setMode('preview')}>
|
||||||
on:save={handleSave}
|
{t('note.mode.preview')}
|
||||||
on:mode-change={handleModeChange}
|
</button>
|
||||||
on:insert-link={handleInsertLink}
|
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'split'} class:active={viewMode === 'split'} on:click={() => setMode('split')}>
|
||||||
on:insert-internal-link={handleInsertInternalLink}
|
{t('note.mode.split')}
|
||||||
on:verstak-link={handleVerstakLink}
|
</button>
|
||||||
on:close={() => dispatch('close')}
|
</div>
|
||||||
/>
|
|
||||||
|
<!-- Content area -->
|
||||||
|
<div class="panel-content">
|
||||||
|
{#if viewMode === 'edit'}
|
||||||
|
<MarkdownEditor
|
||||||
|
bind:this={activeEditor}
|
||||||
|
{content}
|
||||||
|
{placeholder}
|
||||||
|
viewMode="edit"
|
||||||
|
on:content-change={handleContentChange}
|
||||||
|
on:save={handleSave}
|
||||||
|
on:insert-link={handleInsertLink}
|
||||||
|
on:insert-internal-link={handleInsertInternalLink}
|
||||||
|
/>
|
||||||
|
{:else if viewMode === 'preview'}
|
||||||
|
<div class="preview-pane">
|
||||||
|
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === 'split'}
|
||||||
|
<div class="split-pane">
|
||||||
|
<div class="split-editor">
|
||||||
|
<MarkdownEditor
|
||||||
|
bind:this={activeEditor}
|
||||||
|
{content}
|
||||||
|
{placeholder}
|
||||||
|
viewMode="split"
|
||||||
|
on:content-change={handleContentChange}
|
||||||
|
on:save={handleSave}
|
||||||
|
on:insert-link={handleInsertLink}
|
||||||
|
on:insert-internal-link={handleInsertInternalLink}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="split-preview">
|
||||||
|
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.note-editor-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-bottom: 1px solid #2a2a3c;
|
||||||
|
background: #14141f;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn:hover {
|
||||||
|
color: #ccc;
|
||||||
|
background: #1e1e30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn.active {
|
||||||
|
color: #e4e4ef;
|
||||||
|
background: #22223a;
|
||||||
|
border-color: #333350;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn:focus-visible {
|
||||||
|
outline: 2px solid #818cf8;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-preview .panel-content {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-editor {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
border-right: 1px solid #2a2a3c;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-preview {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: #11111c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,82 +1,81 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { t } from '../../i18n'
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
export let notes = []
|
|
||||||
export let formatDate = (str) => ''
|
|
||||||
|
|
||||||
// ===== Events =====
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
// ===== Internal state =====
|
export let notes = []
|
||||||
let showCreateNote = false
|
export let showCreateNote = false
|
||||||
|
export let formatDate = (d) => d || ''
|
||||||
|
|
||||||
let newNoteTitle = ''
|
let newNoteTitle = ''
|
||||||
|
|
||||||
function openCreateNote() {
|
function handleCreateNote() {
|
||||||
showCreateNote = true
|
dispatch('createNote')
|
||||||
newNoteTitle = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCreateNote() {
|
function handleSubmitCreateNote() {
|
||||||
showCreateNote = false
|
if (newNoteTitle.trim()) {
|
||||||
newNoteTitle = ''
|
dispatch('submitCreateNote', { title: newNoteTitle.trim() })
|
||||||
|
newNoteTitle = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitCreateNote() {
|
function handleCancelCreateNote() {
|
||||||
if (!newNoteTitle.trim()) return
|
dispatch('cancelCreateNote')
|
||||||
dispatch('submitCreateNote', { title: newNoteTitle.trim() })
|
|
||||||
showCreateNote = false
|
|
||||||
newNoteTitle = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreateKeydown(e) {
|
function handleOpenNote(note) {
|
||||||
if (e.key === 'Enter') submitCreateNote()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openNote(note) {
|
|
||||||
dispatch('openNote', { note })
|
dispatch('openNote', { note })
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRename(noteId, currentTitle) {
|
function handleStartRename(note) {
|
||||||
dispatch('startRename', { noteId, currentTitle })
|
dispatch('startRename', { note })
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteNoteHandler(note) {
|
function handleDelete(note) {
|
||||||
dispatch('deleteNote', { note })
|
dispatch('delete', { note })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyActivate(fn) {
|
||||||
|
return (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="notes-tab">
|
<div class="notes-tab">
|
||||||
<div class="tab-toolbar">
|
<div class="tab-toolbar">
|
||||||
<button class="btn btn-primary" on:click={openCreateNote}>Добавить заметку</button>
|
<button class="btn btn-primary" on:click={handleCreateNote}>{t('note.add')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showCreateNote}
|
{#if showCreateNote}
|
||||||
<div class="create-form">
|
<div class="create-form">
|
||||||
<input type="text" placeholder="Название заметки" bind:value={newNoteTitle}
|
<input type="text" placeholder={t('note.title')} bind:value={newNoteTitle}
|
||||||
on:keydown={handleCreateKeydown} />
|
on:keydown={(e) => e.key === 'Enter' && handleSubmitCreateNote()} />
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn-primary" on:click={submitCreateNote}>Создать</button>
|
<button class="btn btn-primary" on:click={handleSubmitCreateNote}>{t('common.create')}</button>
|
||||||
<button class="btn" on:click={cancelCreateNote}>Отмена</button>
|
<button class="btn" on:click={handleCancelCreateNote}>{t('common.cancel')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if notes.length === 0 && !showCreateNote}
|
{#if notes.length === 0 && !showCreateNote}
|
||||||
<div class="empty-state"><p>Нет заметок</p><p class="hint">Создайте первую заметку</p></div>
|
<div class="empty-state"><p>{t('note.noNotes')}</p><p class="hint">{t('note.createFirst')}</p></div>
|
||||||
{:else if notes.length > 0}
|
{:else}
|
||||||
<div class="notes-list">
|
<div class="notes-list">
|
||||||
{#each notes as note}
|
{#each notes as note}
|
||||||
<div class="note-card" role="button" tabindex="0" on:click={() => openNote(note)} on:keydown={(e) => e.key === 'Enter' && openNote(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-info">
|
||||||
<div class="note-card-title">{note.title}</div>
|
<div class="note-card-title">{note.title}</div>
|
||||||
<div class="note-card-date">{formatDate(note.createdAt)}</div>
|
<div class="note-card-date">{formatDate(note.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="note-card-actions" on:click|stopPropagation>
|
<div class="note-card-actions" on:click|stopPropagation>
|
||||||
<button class="note-action-btn" on:click={() => startRename(note.id, note.title)} title="Переименовать">
|
<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>
|
<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>
|
||||||
<button class="note-action-btn note-action-danger" on:click={() => deleteNoteHandler(note)} title="Удалить">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,16 +89,16 @@
|
|||||||
.notes-tab { padding: 24px; }
|
.notes-tab { padding: 24px; }
|
||||||
.tab-toolbar { margin-bottom: 16px; }
|
.tab-toolbar { margin-bottom: 16px; }
|
||||||
.create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
|
.create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
|
||||||
.create-form input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 8px; }
|
.create-form input { 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 { outline: none; border-color: #6366f1; }
|
.create-form input:focus { border-color: #6366f1; }
|
||||||
.form-actions { display: flex; gap: 8px; }
|
.form-actions { display: flex; gap: 8px; }
|
||||||
.notes-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
.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; position: relative; }
|
.note-card { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 16px; cursor: pointer; }
|
||||||
.note-card:hover { border-color: #3a3a5c; }
|
.note-card:hover { border-color: #3a3a5c; }
|
||||||
.note-card-info { flex: 1; min-width: 0; }
|
|
||||||
.note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
|
.note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
|
||||||
.note-card-date { font-size: 11px; color: #555; }
|
.note-card-date { font-size: 11px; color: #555; }
|
||||||
.note-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; position: absolute; top: 8px; right: 8px; }
|
.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-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 { 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-btn:hover { background: #2a2a3c; color: #ccc; }
|
||||||
@@ -107,14 +106,9 @@
|
|||||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
|
.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 p { margin: 0; font-size: 14px; color: #666; }
|
||||||
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
|
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
|
||||||
|
|
||||||
/* Button styles (mirroring global App.svelte .btn) */
|
|
||||||
.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 { 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:hover { background: #222233; }
|
||||||
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
|
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
|
||||||
.btn-primary:hover { background: #4f46e5; }
|
.btn-primary:hover { background: #4f46e5; }
|
||||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||||
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
|
|
||||||
.btn-danger:hover { background: #3a2222; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ export function getFileKind(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageMimes = ['image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/avif','image/svg+xml']
|
const imageMimes = ['image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/avif','image/svg+xml']
|
||||||
const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript','text/csv','text/tab-separated-values','text/x-python','text/x-java','text/x-c','text/x-cpp','text/x-ruby','text/x-perl','text/x-php','text/x-go','text/x-rust','text/markdown']
|
const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript']
|
||||||
const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','tsv','sh','py','js','ts','css','html','md','markdown','cfg','env','gitignore','dockerignore','toml','bat','cmd','ps1','sql','graphql','proto','gradle','cmake','makefile','dockerfile','vbs','lua','r','m','scala','kt','swift','dart','elm','erl','ex','exs','fs','fsi','fsx','hs','lhs','ml','mli','pl','pm','rb','rake','rs','scm','ss','clj','cljs','cljc','edn','lisp','lsp','el','vim','vimrc','zsh','bash','fish','csh','ksh','tcsh','awk','sed','make','mk','nim','nims','d','go','java','svelte','vue','jsx','tsx','coffee','litcoffee','scss','sass','less','styl','properties','dotenv','editorconfig','gitattributes','browserslistrc','htaccess','nginx','shader','glsl','vert','frag','asm','h','hpp','cxx','cc','cpp','capnp','flatbuf','prisma','dtd','xsl','xsd','sch','svg','vtt','srt','sub','m3u','m3u8','pls','xspf','cue','toc','nfo','diz','readme','changelog','copying','license','authors','contributors','setup','config','strings','po','pot','locale','translation']
|
const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','sh','py','js','ts','css','html','md','markdown','cfg']
|
||||||
|
|
||||||
const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']
|
const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Activity API — activity feed and per-node activity.
|
||||||
|
*/
|
||||||
|
import { createApi } from './wails.js'
|
||||||
|
|
||||||
|
export const listActivityFeed = createApi('ListActivityFeed')
|
||||||
|
export const listActivityByNode = createApi('ListActivityByNode')
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Sync API — vault synchronisation.
|
||||||
|
*/
|
||||||
|
import { createApi } from './wails.js'
|
||||||
|
|
||||||
|
export const syncStatus = createApi('SyncStatus')
|
||||||
|
export const syncNow = createApi('SyncNow')
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-45
@@ -1,48 +1,5 @@
|
|||||||
import App from './App.svelte'
|
import App from './App.svelte'
|
||||||
|
|
||||||
// ===== Global frontend error diagnostics =====
|
new App({
|
||||||
// These catch runtime errors that would otherwise cause a silent blank window.
|
target: document.getElementById('app')
|
||||||
|
|
||||||
window.addEventListener('error', (event) => {
|
|
||||||
const msg = event.error ? String(event.error) : event.message
|
|
||||||
const stack = event.error && event.error.stack ? event.error.stack : ''
|
|
||||||
console.error('[Frontend Error]', msg, stack)
|
|
||||||
try {
|
|
||||||
if (window['go'] && window['go']['main'] && window['go']['main']['App'] && typeof window['go']['main']['App']['FrontendLog'] === 'function') {
|
|
||||||
window['go']['main']['App']['FrontendLog']('error', msg, stack)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Backend not ready — ignore
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('unhandledrejection', (event) => {
|
|
||||||
const reason = event.reason
|
|
||||||
const msg = reason instanceof Error ? String(reason) : JSON.stringify(reason)
|
|
||||||
const stack = reason instanceof Error && reason.stack ? reason.stack : ''
|
|
||||||
console.error('[Unhandled Promise Rejection]', msg, stack)
|
|
||||||
try {
|
|
||||||
if (window['go'] && window['go']['main'] && window['go']['main']['App'] && typeof window['go']['main']['App']['FrontendLog'] === 'function') {
|
|
||||||
window['go']['main']['App']['FrontendLog']('error', 'Unhandled rejection: ' + msg, stack)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Backend not ready — ignore
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mount App with error fallback
|
|
||||||
try {
|
|
||||||
new App({
|
|
||||||
target: document.getElementById('app')
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Fatal] App mount failed:', e)
|
|
||||||
const el = document.getElementById('app')
|
|
||||||
if (el) {
|
|
||||||
el.innerHTML = '<div style="padding:24px;color:#ff6b6b;font-family:monospace;white-space:pre-wrap">'
|
|
||||||
+ '<h2>Frontend Runtime Error</h2>'
|
|
||||||
+ '<p>' + String(e) + '</p>'
|
|
||||||
+ (e.stack ? '<pre>' + e.stack + '</pre>' : '')
|
|
||||||
+ '</div>'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -243,25 +243,5 @@ export function CountActivityByNode(arg1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CreateEmptyFile(arg1, arg2) {
|
export function CreateEmptyFile(arg1, arg2) {
|
||||||
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2)
|
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2);
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Logging & Diagnostics =====
|
|
||||||
|
|
||||||
export function FrontendLog(level, message, stack) {
|
|
||||||
try {
|
|
||||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
|
||||||
return window['go']['main']['App']['FrontendLog'](level, message, stack || '')
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LogStartupStep(step, success, detail) {
|
|
||||||
try {
|
|
||||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
|
||||||
return window['go']['main']['App']['LogStartupStep'](step, success, detail || '')
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,28 +330,6 @@ func (s *Service) ReadText(id string) (string, error) {
|
|||||||
return string(b), nil
|
return string(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteText writes text content to a file on disk and updates the record.
|
|
||||||
func (s *Service) WriteText(rec *Record, content string) error {
|
|
||||||
abs, err := s.absPathSafe(rec)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tmp := abs + ".tmp"
|
|
||||||
if err := os.WriteFile(tmp, []byte(content), 0o640); err != nil {
|
|
||||||
return fmt.Errorf("write temp: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, abs); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("rename: %w", err)
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
sum := sha256.Sum256([]byte(content))
|
|
||||||
_, err = s.db.Exec(
|
|
||||||
`UPDATE files SET size=?, sha256=?, updated_at=?, missing=? WHERE id=?`,
|
|
||||||
len(content), fmt.Sprintf("%x", sum[:]), now, 0, rec.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadBase64 reads a file and returns a data URI (base64-encoded).
|
// ReadBase64 reads a file and returns a data URI (base64-encoded).
|
||||||
func (s *Service) ReadBase64(id string) (string, error) {
|
func (s *Service) ReadBase64(id string) (string, error) {
|
||||||
rec, err := s.Get(id)
|
rec, err := s.Get(id)
|
||||||
|
|||||||
Reference in New Issue
Block a user