Compare commits
8 Commits
master
...
refactor/a
| Author | SHA1 | Date |
|---|---|---|
|
|
120ff7d6fe | |
|
|
383a9546df | |
|
|
d7d806530b | |
|
|
82f59ab8da | |
|
|
208cd970d7 | |
|
|
58cdd61d27 | |
|
|
acdbbdfa55 | |
|
|
df21340402 |
|
|
@ -210,6 +210,46 @@ func (a *App) CheckFileAction(fileID string) (*PreflightFileAction, error) {
|
|||
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) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
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;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-CzfuqGWF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
|
||||
<script type="module" crossorigin src="/assets/main-B99YW--H.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CtgLvi_n.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
# Frontend Architecture — Verstak GUI
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Svelte 4 (runes-free, compiler-only)
|
||||
- **Build tool**: Vite 5
|
||||
- **GUI shell**: Wails v2 (Go backend + WebKit GTK frontend)
|
||||
- **Language**: Plain JavaScript (no TypeScript in Svelte files — `lang="ts"` is NOT used)
|
||||
|
||||
## Source Structure
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
main.js # Entry: mounts App.svelte, global error handlers
|
||||
App.svelte # Root component: sidebar, header, modals, tab router
|
||||
FileTreeRow.svelte # Sidebar tree node row
|
||||
TreeNode.svelte # Recursive tree node
|
||||
lib/
|
||||
AppHeader.svelte # Top bar with title, search, actions
|
||||
BrowserEvents.svelte # Browser extension events panel
|
||||
CalendarPluginPage.svelte # Plugin page host
|
||||
ConfirmModal.svelte # Reusable confirm dialog
|
||||
FileBreadcrumbs.svelte # File tab breadcrumb navigation
|
||||
FileIcon.svelte # Icon resolver for file types
|
||||
FilePreviewModal.svelte # File content preview modal
|
||||
FirstRun.svelte # First-run wizard
|
||||
GlobalSearch.svelte # Global search bar + results
|
||||
SettingsWindow.svelte # Settings modal container
|
||||
Settings*.svelte # Settings sub-sections (General, Sync, etc.)
|
||||
SyncStatus.svelte # Sync status badge
|
||||
TemplateIcon.svelte # Template type icon
|
||||
TodayScreen.svelte # Today dashboard
|
||||
VaultRecovery.svelte # Vault recovery wizard
|
||||
actionIcons.js # SVG icon strings
|
||||
fileUtils.js # File type helpers (canPreview, isMarkdown, etc.)
|
||||
i18n/ # Internationalization (en, ru)
|
||||
markdown/ # Markdown rendering + internal links
|
||||
util/ # Keyboard layout helper
|
||||
components/
|
||||
OverviewTab.svelte # Overview tab: meta, quick actions, recent items
|
||||
files/
|
||||
FilesTab.svelte # Files tab: file browser, preview, import, rename
|
||||
notes/
|
||||
NotesTab.svelte # Notes tab: note list, create form
|
||||
MarkdownEditor.svelte # Markdown textarea with toolbar
|
||||
MarkdownPreview.svelte # Rendered markdown preview
|
||||
NoteEditorPanel.svelte # Editor + preview layout, public API: insertText()
|
||||
InternalLinkPicker.svelte # Object picker for verstak:// links
|
||||
ObjectPickerModal.svelte # Legacy object picker modal
|
||||
```
|
||||
|
||||
## Role of App.svelte
|
||||
|
||||
`App.svelte` is the **root component**. It owns:
|
||||
|
||||
1. **Global UI state**: sidebar (system views, workspace tree), active tab, selected node/section
|
||||
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:
|
||||
- **Files tab** → `FilesTab.svelte` (owns all file browser state, preview, import, rename)
|
||||
- **Notes tab list** → `NotesTab.svelte` (owns note list UI, create form; editor stays in App)
|
||||
- **Overview tab** → `OverviewTab.svelte` (pure display: meta, quick actions, recent items)
|
||||
- **Settings sections** → each has its own `Settings*.svelte`
|
||||
|
||||
## Component Communication
|
||||
|
||||
### Props (parent → child)
|
||||
Data flows down via Svelte `export let prop`
|
||||
|
||||
### Events (child → parent)
|
||||
Children dispatch events via `createEventDispatcher()`
|
||||
|
||||
### Public API (bind:this)
|
||||
Parent gets imperative handle via `bind:this={ref}` and calls:
|
||||
- `ref.publicMethod(args)` — guard with optional chaining: `ref?.method?.(args)`
|
||||
|
||||
## Component Reference
|
||||
|
||||
### OverviewTab (`lib/components/OverviewTab.svelte`)
|
||||
**Props**: `selectedNode`, `notes`, `worklog`, `formatDate`, `nodeKindLabel`
|
||||
**Events**: `createNote`, `addFile`, `createAction`, `switchTab`, `openNote`
|
||||
**State**: None (pure display)
|
||||
|
||||
### NotesTab (`lib/components/notes/NotesTab.svelte`)
|
||||
**Props**: `notes`, `formatDate`
|
||||
**Events**: `submitCreateNote`({title}), `openNote`({note}), `startRename`({noteId, currentTitle}), `deleteNote`({note})
|
||||
**State**: `showCreateNote`, `newNoteTitle`
|
||||
|
||||
### FilesTab (`lib/components/files/FilesTab.svelte`)
|
||||
**Props**: `selectedNode`, `wailsCall`
|
||||
**Events**: `openNote`({id, title}), `refreshParent`({nodeId}), `error`({message})
|
||||
**Public API** (via bind:this):
|
||||
- `resetToNode(nodeId)` — reset to root of given node
|
||||
- `addFile()` — open file picker and import
|
||||
- `loadFolder(folderId)` — load folder contents
|
||||
- `openFileById(fileNodeId)` — find and preview file
|
||||
- `focusItem(nodeId)` — select item by ID
|
||||
- `handleFilesKeydown(e)` — delegate keyboard handling
|
||||
- `resetState()` — full reset (on node change)
|
||||
|
||||
**Internal state** (owned by FilesTab, NOT accessible from App):
|
||||
- `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
|
||||
|
||||
### App.svelte MUST own:
|
||||
- `selectedSection`, `selectedNode`, `activeTab`
|
||||
- `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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -88,7 +88,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
function toggleMenu(e) {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +119,7 @@
|
|||
e.preventDefault()
|
||||
menuX = Math.min(e.clientX, window.innerWidth - 240)
|
||||
menuY = Math.min(e.clientY, window.innerHeight - 320)
|
||||
console.log('[FileTreeRow] menu source=contextmenu x=' + menuX + ' y=' + menuY)
|
||||
menuOpen = true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
import { createEventDispatcher, onMount, onDestroy } from '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 { t } from './i18n'
|
||||
|
||||
|
|
@ -13,7 +14,8 @@
|
|||
|
||||
const kind = getFileKind(item)
|
||||
$: showImage = isImageFile(item) && content && content.startsWith('data:')
|
||||
$: showText = isTextFile(item) || isMarkdownFile(item)
|
||||
$: showMarkdown = isMarkdownFile(item) && content
|
||||
$: showText = (isTextFile(item) || isMarkdownFile(item)) && content && !showMarkdown
|
||||
$: showPdf = isPdfFile(item)
|
||||
|
||||
function handleKeydown(e) {
|
||||
|
|
@ -44,13 +46,6 @@
|
|||
</div>
|
||||
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||
<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">
|
||||
<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"/>
|
||||
|
|
@ -71,7 +66,11 @@
|
|||
<div class="preview-image-container">
|
||||
<img src={content} alt={item.name} class="preview-image"/>
|
||||
</div>
|
||||
{:else if showText && content}
|
||||
{:else if showMarkdown}
|
||||
<div class="preview-markdown-container">
|
||||
<MarkdownPreview {content} />
|
||||
</div>
|
||||
{:else if showText}
|
||||
<pre class="preview-text"><code>{content}</code></pre>
|
||||
{:else if showPdf}
|
||||
{#if content && content.startsWith('data:')}
|
||||
|
|
@ -91,6 +90,9 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<footer class="preview-footer">
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -260,4 +262,20 @@
|
|||
.btn-sm:hover {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { t } from '../i18n'
|
||||
|
||||
// ===== Props =====
|
||||
export let selectedNode = null
|
||||
export let notes = []
|
||||
export let worklog = []
|
||||
export let formatDate = (str) => ''
|
||||
export let nodeKindLabel = (kind) => kind || ''
|
||||
|
||||
// ===== Events =====
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function createNote() {
|
||||
dispatch('createNote')
|
||||
}
|
||||
|
||||
function addFile() {
|
||||
dispatch('addFile')
|
||||
}
|
||||
|
||||
function createAction() {
|
||||
dispatch('createAction')
|
||||
}
|
||||
|
||||
function logTime() {
|
||||
dispatch('switchTab', { tab: 'worklog' })
|
||||
}
|
||||
|
||||
function openNoteHandler(note) {
|
||||
dispatch('openNote', { note })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overview">
|
||||
<h2>{selectedNode.title}</h2>
|
||||
<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.section')}</span><span>{selectedNode.section || '—'}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(selectedNode.createdAt)}</span></div>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button class="qa-btn" on:click={createNote}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
{t('overview.newNote')}
|
||||
</button>
|
||||
<button class="qa-btn" on:click={addFile}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
{t('overview.addFile')}
|
||||
</button>
|
||||
<button class="qa-btn" on:click={createAction}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
{t('overview.addAction')}
|
||||
</button>
|
||||
<button class="qa-btn" on:click={logTime}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
{t('overview.logTime')}
|
||||
</button>
|
||||
</div>
|
||||
{#if notes.length > 0}
|
||||
<div class="recent-section">
|
||||
<h3>{t('overview.recentNotes')}</h3>
|
||||
{#each notes.slice(0, 5) as note}
|
||||
<div class="recent-note" role="button" tabindex="0" on:click={() => openNoteHandler(note)} on:keydown={(e) => e.key === 'Enter' && openNoteHandler(note)}>
|
||||
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if worklog.length > 0}
|
||||
<div class="recent-section">
|
||||
<h3>{t('overview.recentEntries')}</h3>
|
||||
{#each worklog.slice(0, 3) as e}
|
||||
<div class="recent-entry">{e.summary} ({e.minutes} {t('worklog.min')})</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overview { padding: 24px; }
|
||||
.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; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,857 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import FileBreadcrumbs from '../../FileBreadcrumbs.svelte'
|
||||
import FileTreeRow from '../../../FileTreeRow.svelte'
|
||||
import FilePreviewModal from '../../FilePreviewModal.svelte'
|
||||
import ConfirmModal from '../../ConfirmModal.svelte'
|
||||
import { canPreviewFile, needsBase64Preview, isTextFile, isMarkdownFile } from '../../fileUtils.js'
|
||||
import { t } from '../../i18n'
|
||||
import EditorPanel from '../notes/EditorPanel.svelte'
|
||||
|
||||
// ===== Props =====
|
||||
export let selectedNode = null
|
||||
export let wailsCall = async () => {}
|
||||
|
||||
// ===== Debug helper =====
|
||||
function _fdl(msg) {
|
||||
try { wailsCall('WriteDebugLog', '[FilesTab] ' + msg) } catch(e) {}
|
||||
}
|
||||
|
||||
// ===== Events =====
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// ===== Internal state (owned by FilesTab) =====
|
||||
let loadingFiles = false
|
||||
let currentFolderId = null
|
||||
let folderStack = []
|
||||
let fileItems = []
|
||||
// Preview modal state (images, PDF, binary)
|
||||
let previewItem = null
|
||||
let previewContent = ''
|
||||
let previewLoading = false
|
||||
let previewError = ''
|
||||
// Text file editor state (text, markdown, code)
|
||||
let textEditorFile = null
|
||||
let textEditorContent = ''
|
||||
let textEditorLoading = false
|
||||
let textEditorError = ''
|
||||
let textEditorIsMarkdown = false
|
||||
let textEditorMode = 'edit' // 'edit' | 'preview' | 'split'
|
||||
let textEditorDirty = false
|
||||
let textEditorIsNote = false
|
||||
let textEditorNoteId = ''
|
||||
let clipboard = { items: [], mode: 'copy' }
|
||||
let selectedIds = []
|
||||
let dragIds = []
|
||||
let importing = false
|
||||
let importSummary = null
|
||||
let showImportDialog = false
|
||||
let pendingImportPath = ''
|
||||
let pendingImportParent = ''
|
||||
let showRename = false
|
||||
let renameId = ''
|
||||
let renameValue = ''
|
||||
let renameError = ''
|
||||
let showConfirm = false
|
||||
let confirmTitle = ''
|
||||
let confirmMessage = ''
|
||||
let confirmDanger = false
|
||||
let confirmText = t('common.delete')
|
||||
let confirmAction = null
|
||||
let cancelAction = null
|
||||
|
||||
// ===== Public API (called via bind:this) =====
|
||||
export function resetToNode(nodeId) {
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
selectedIds = []
|
||||
dragIds = []
|
||||
previewItem = null
|
||||
previewContent = ''
|
||||
closeTextEditor()
|
||||
loadFolder(nodeId)
|
||||
}
|
||||
|
||||
// ===== React to selectedNode changes =====
|
||||
let lastLoadedNodeId = null
|
||||
$: if (selectedNode && selectedNode.id && selectedNode.id !== lastLoadedNodeId) {
|
||||
_fdl('selectedNode changed: id=' + selectedNode.id + ' title=' + selectedNode.title + ' type=' + selectedNode.type)
|
||||
lastLoadedNodeId = selectedNode.id
|
||||
resetToNode(selectedNode.id)
|
||||
}
|
||||
|
||||
export function addFile() {
|
||||
_addFile()
|
||||
}
|
||||
|
||||
export function loadFolder(folderId) {
|
||||
_loadFolder(folderId)
|
||||
}
|
||||
|
||||
export function openFileById(fileNodeId) {
|
||||
// Find the file in the current fileItems and open preview
|
||||
const fileItem = fileItems.find(f => f.id === fileNodeId)
|
||||
if (fileItem) {
|
||||
_openFile(fileItem)
|
||||
}
|
||||
}
|
||||
|
||||
export function focusItem(nodeId) {
|
||||
selectedIds = [nodeId]
|
||||
}
|
||||
|
||||
// ===== Folder navigation =====
|
||||
async function _loadFolder(folderId) {
|
||||
loadingFiles = true
|
||||
try {
|
||||
let items = await wailsCall('ListItems', folderId) || []
|
||||
_fdl('loadFolder nodeId=' + folderId + ' count=' + items.length + ' first=' + (items[0] ? JSON.stringify({id: items[0].id, name: items[0].name, type: items[0].type, fileId: items[0].fileId}) : 'none'))
|
||||
items.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
|
||||
return (a.name || '').localeCompare(b.name || '')
|
||||
})
|
||||
fileItems = items
|
||||
} catch (e) {
|
||||
_fdl('loadFolder ERROR nodeId=' + folderId + ' err=' + String(e))
|
||||
fileItems = []
|
||||
}
|
||||
loadingFiles = false
|
||||
}
|
||||
|
||||
async function navigateToFolder(folderId) {
|
||||
if (!selectedNode) return
|
||||
try {
|
||||
const node = await wailsCall('GetNodeDetail', folderId)
|
||||
if (node) {
|
||||
folderStack = [...folderStack, { id: folderId, name: node.title }]
|
||||
}
|
||||
} catch (e) {
|
||||
folderStack = [...folderStack, { id: folderId, name: '...' }]
|
||||
}
|
||||
currentFolderId = folderId
|
||||
await _loadFolder(folderId)
|
||||
}
|
||||
|
||||
function navigateBack() {
|
||||
if (folderStack.length < 2) {
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
if (selectedNode) _loadFolder(selectedNode.id)
|
||||
} else {
|
||||
const target = folderStack[folderStack.length - 2]
|
||||
folderStack = folderStack.slice(0, -1)
|
||||
currentFolderId = target.id
|
||||
_loadFolder(target.id)
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToBreadcrumb(index) {
|
||||
const target = folderStack[index]
|
||||
folderStack = folderStack.slice(0, index + 1)
|
||||
currentFolderId = target.id
|
||||
_loadFolder(target.id)
|
||||
}
|
||||
|
||||
// ===== Open file: text editor or preview modal =====
|
||||
async function _openFile(item) {
|
||||
_fdl('openFile item=' + JSON.stringify({id: item.id, name: item.name, type: item.type, fileId: item.fileId}))
|
||||
|
||||
// 1. Markdown: check if linked to a note
|
||||
if (item && item.fileId && isMarkdownFile(item)) {
|
||||
try {
|
||||
const action = await wailsCall('CheckFileAction', item.fileId)
|
||||
_fdl('CheckFileAction result=' + JSON.stringify(action))
|
||||
if (action && action.action === 'note') {
|
||||
// Linked note → open note editor
|
||||
dispatch('openNote', { id: action.noteId, title: action.noteTitle })
|
||||
return
|
||||
}
|
||||
// action === 'preview' → fall through to text editor below
|
||||
} catch (e) {
|
||||
_fdl('CheckFileAction ERROR: ' + String(e))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Text/markdown files → TextFileEditor
|
||||
if (item && item.fileId && (isTextFile(item) || isMarkdownFile(item))) {
|
||||
_fdl('opening text editor for ' + item.name)
|
||||
openTextEditor(item, isMarkdownFile(item))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Images / PDF / binary preview → FilePreviewModal
|
||||
if (item && item.fileId && canPreviewFile(item)) {
|
||||
_fdl('opening preview modal for ' + item.name)
|
||||
openPreviewModal(item)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Fallback: unknown type, try as text
|
||||
if (item && item.fileId && item.type !== 'folder') {
|
||||
_fdl('fallback: trying text editor for unknown type ' + item.name)
|
||||
openTextEditor(item, false)
|
||||
return
|
||||
}
|
||||
|
||||
_fdl('no suitable opener for ' + item.name)
|
||||
}
|
||||
|
||||
async function openTextEditor(item, isMarkdown, noteId) {
|
||||
textEditorFile = item
|
||||
textEditorContent = ''
|
||||
textEditorError = ''
|
||||
textEditorLoading = true
|
||||
textEditorIsMarkdown = isMarkdown
|
||||
textEditorMode = 'edit'
|
||||
textEditorDirty = false
|
||||
textEditorIsNote = !!noteId
|
||||
textEditorNoteId = noteId || ''
|
||||
try {
|
||||
textEditorContent = await wailsCall('ReadFileText', item.fileId) || ''
|
||||
} catch (e) {
|
||||
textEditorError = String(e)
|
||||
}
|
||||
textEditorLoading = false
|
||||
}
|
||||
|
||||
function closeTextEditor() {
|
||||
textEditorFile = null
|
||||
textEditorContent = ''
|
||||
textEditorError = ''
|
||||
textEditorIsMarkdown = false
|
||||
textEditorMode = 'view'
|
||||
textEditorDirty = false
|
||||
}
|
||||
|
||||
function saveTextEditor() {
|
||||
if (!textEditorFile || !textEditorDirty) return
|
||||
wailsCall('SaveFileText', textEditorFile.fileId, textEditorContent)
|
||||
textEditorDirty = false
|
||||
}
|
||||
|
||||
async function openPreviewModal(item) {
|
||||
previewItem = item
|
||||
previewContent = ''
|
||||
previewError = ''
|
||||
previewLoading = true
|
||||
try {
|
||||
if (needsBase64Preview(item)) {
|
||||
previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
|
||||
}
|
||||
} catch (e) {
|
||||
previewError = String(e)
|
||||
}
|
||||
previewLoading = false
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewItem = null
|
||||
previewContent = ''
|
||||
previewError = ''
|
||||
}
|
||||
|
||||
// ===== File operations =====
|
||||
async function _addFile() {
|
||||
const path = await wailsCall('PickFile')
|
||||
if (!path) return
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await _startImport(parentId, path)
|
||||
}
|
||||
|
||||
async function _addFolder() {
|
||||
const path = await wailsCall('PickDirectory')
|
||||
if (!path) return
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await _startImport(parentId, path)
|
||||
}
|
||||
|
||||
async function createFile() {
|
||||
const name = prompt(t('file.namePrompt'))
|
||||
if (!name || !name.trim()) return
|
||||
try {
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await wailsCall('CreateEmptyFile', parentId, name.trim())
|
||||
await _loadFolder(parentId)
|
||||
dispatch('refreshParent', { nodeId: parentId })
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateItem(id) {
|
||||
try {
|
||||
await wailsCall('DuplicateNode', id)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await _loadFolder(parentId)
|
||||
dispatch('refreshParent', { nodeId: parentId })
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
function renameItem(id) {
|
||||
const item = fileItems.find(x => x.id === id)
|
||||
if (item) _openRename(item.id, item.name)
|
||||
}
|
||||
|
||||
function cutItem(id) {
|
||||
clipboard = { items: [id], mode: 'cut' }
|
||||
}
|
||||
|
||||
function copyItem(id) {
|
||||
clipboard = { items: [id], mode: 'copy' }
|
||||
}
|
||||
|
||||
async function pasteItem() {
|
||||
if (clipboard.items.length === 0) return
|
||||
const targetId = currentFolderId || selectedNode.id
|
||||
try {
|
||||
if (clipboard.mode === 'copy') {
|
||||
for (const id of clipboard.items) {
|
||||
await wailsCall('DuplicateNode', id)
|
||||
}
|
||||
} else {
|
||||
for (const id of clipboard.items) {
|
||||
await wailsCall('MoveNode', id, targetId)
|
||||
}
|
||||
}
|
||||
clipboard = { items: [], mode: 'copy' }
|
||||
await _loadFolder(targetId)
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Selection =====
|
||||
function toggleSelection(id) {
|
||||
if (selectedIds.includes(id)) {
|
||||
selectedIds = selectedIds.filter(x => x !== id)
|
||||
} else {
|
||||
selectedIds = [...selectedIds, id]
|
||||
}
|
||||
}
|
||||
|
||||
function selectOne(id) {
|
||||
selectedIds = [id]
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedIds = fileItems.map(x => x.id)
|
||||
}
|
||||
|
||||
function rangeSelect(id) {
|
||||
if (fileItems.length === 0) return
|
||||
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
|
||||
const lastIdx = fileItems.findIndex(x => x.id === lastId)
|
||||
const curIdx = fileItems.findIndex(x => x.id === id)
|
||||
if (lastIdx === -1 || curIdx === -1) return
|
||||
const start = Math.min(lastIdx, curIdx)
|
||||
const end = Math.max(lastIdx, curIdx)
|
||||
const range = fileItems.slice(start, end + 1).map(x => x.id)
|
||||
const set = new Set(selectedIds)
|
||||
range.forEach(x => set.add(x))
|
||||
selectedIds = [...set]
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds = []
|
||||
}
|
||||
|
||||
function getTargetIds(ids) {
|
||||
return ids.length > 0 ? ids : fileItems.map(x => x.id)
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
const ids = getTargetIds(selectedIds)
|
||||
const item = fileItems.find(x => x.id === ids[0])
|
||||
let label
|
||||
if (ids.length === 1 && item?.type === 'folder') {
|
||||
label = t('delete.folder')
|
||||
} else if (ids.length === 1) {
|
||||
label = t('delete.file')
|
||||
} else {
|
||||
label = t('delete.files', { count: ids.length })
|
||||
}
|
||||
_openConfirm({
|
||||
title: t('delete.confirmTitle'),
|
||||
message: t('delete.confirmMessage') + ' ' + label + '?',
|
||||
confirmText: t('common.delete'),
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await wailsCall('DeleteFileOrFolder', id)
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
selectedIds = []
|
||||
const reloadId = currentFolderId || selectedNode.id
|
||||
await _loadFolder(reloadId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cutSelected() {
|
||||
const ids = getTargetIds(selectedIds)
|
||||
clipboard = { items: ids, mode: 'cut' }
|
||||
selectedIds = []
|
||||
}
|
||||
|
||||
function copySelected() {
|
||||
const ids = getTargetIds(selectedIds)
|
||||
clipboard = { items: ids, mode: 'copy' }
|
||||
selectedIds = []
|
||||
}
|
||||
|
||||
// ===== Drag-and-drop =====
|
||||
function onDragStart(e, id) {
|
||||
e.stopPropagation()
|
||||
const ids = selectedIds.includes(id) ? selectedIds : [id]
|
||||
dragIds = ids
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', ids.join(','))
|
||||
}
|
||||
|
||||
function onDragOver(e, folderId) {
|
||||
const item = fileItems.find(x => x.id === folderId)
|
||||
if (item && item.type === 'folder') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(e, folderId) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (dragIds.length === 0) return
|
||||
for (const id of dragIds) {
|
||||
try {
|
||||
await wailsCall('MoveNode', id, folderId)
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
dragIds = []
|
||||
selectedIds = []
|
||||
await _loadFolder(currentFolderId || selectedNode.id)
|
||||
}
|
||||
|
||||
// ===== Keyboard =====
|
||||
function openSelected() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
if (item.type === 'folder') {
|
||||
navigateToFolder(item.id)
|
||||
} else {
|
||||
_openFile(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectedExternal() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item && item.fileId) {
|
||||
wailsCall('OpenFile', item.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openRenameForSelection() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
_openRename(item.id, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Rename modal =====
|
||||
function _openRename(id, currentName) {
|
||||
renameId = id
|
||||
renameValue = currentName
|
||||
renameError = ''
|
||||
showRename = true
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
dispatch('refreshParent', { nodeId: currentFolderId || selectedNode.id })
|
||||
if (currentFolderId) {
|
||||
await _loadFolder(currentFolderId)
|
||||
}
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
showRename = false
|
||||
renameId = ''
|
||||
renameValue = ''
|
||||
renameError = ''
|
||||
}
|
||||
|
||||
function onRenameKeydown(e) {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
else renameError = ''
|
||||
}
|
||||
|
||||
// ===== Confirm modal =====
|
||||
function _openConfirm(opts) {
|
||||
confirmTitle = opts.title || t('common.confirm')
|
||||
confirmMessage = opts.message || ''
|
||||
confirmDanger = opts.danger !== undefined ? opts.danger : true
|
||||
confirmText = opts.confirmText || t('common.delete')
|
||||
confirmAction = opts.onConfirm || null
|
||||
cancelAction = opts.onCancel || null
|
||||
showConfirm = true
|
||||
}
|
||||
|
||||
function closeConfirm() {
|
||||
showConfirm = false
|
||||
confirmAction = null
|
||||
cancelAction = null
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirmAction) confirmAction()
|
||||
closeConfirm()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (cancelAction) cancelAction()
|
||||
closeConfirm()
|
||||
}
|
||||
|
||||
// ===== Import =====
|
||||
async function _startImport(parentID, sourcePath) {
|
||||
importing = true
|
||||
try {
|
||||
const summary = await wailsCall('PreviewImport', sourcePath)
|
||||
importSummary = summary
|
||||
pendingImportPath = sourcePath
|
||||
pendingImportParent = parentID
|
||||
showImportDialog = true
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
importing = false
|
||||
}
|
||||
|
||||
async function confirmImport(mode) {
|
||||
try {
|
||||
const parentId = pendingImportParent || selectedNode.id
|
||||
if (mode === 'copy') {
|
||||
await wailsCall('AddPathCopy', parentId, pendingImportPath)
|
||||
} else {
|
||||
await wailsCall('AddPathLink', parentId, pendingImportPath)
|
||||
}
|
||||
showImportDialog = false
|
||||
importSummary = null
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
await _loadFolder(parentId)
|
||||
dispatch('refreshParent', { nodeId: parentId })
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport() {
|
||||
showImportDialog = false
|
||||
importSummary = null
|
||||
}
|
||||
|
||||
async function deleteFile({ id, type }) {
|
||||
const label = type === 'folder' ? t('delete.folder') : t('delete.file')
|
||||
_openConfirm({
|
||||
title: t('delete.confirmTitle'),
|
||||
message: t('delete.confirmMessage') + ' ' + label + '?',
|
||||
confirmText: t('common.delete'),
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await wailsCall('DeleteFileOrFolder', id)
|
||||
const reloadId = currentFolderId || selectedNode.id
|
||||
await _loadFolder(reloadId)
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openSelectedFile(fileID) {
|
||||
try {
|
||||
await wailsCall('OpenFile', fileID)
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Keyboard handler (called from parent) =====
|
||||
export function handleFilesKeydown(e) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
|
||||
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
openSelected()
|
||||
} else if (e.key === 'Delete') {
|
||||
if (textEditorFile) { e.preventDefault(); closeTextEditor(); return }
|
||||
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
||||
} else if (e.key === 'Escape') {
|
||||
if (textEditorFile) { closeTextEditor(); return }
|
||||
if (previewItem) { closePreview(); return }
|
||||
if (selectedIds.length > 0) { clearSelection(); return }
|
||||
} else if (e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
openRenameForSelection()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Reset on node change =====
|
||||
export function resetState() {
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
fileItems = []
|
||||
selectedIds = []
|
||||
dragIds = []
|
||||
previewItem = null
|
||||
previewContent = ''
|
||||
previewError = ''
|
||||
clipboard = { items: [], mode: 'copy' }
|
||||
importing = false
|
||||
importSummary = null
|
||||
showImportDialog = false
|
||||
showRename = false
|
||||
showConfirm = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Files tab template -->
|
||||
<div class="files-tab">
|
||||
{#if textEditorFile}
|
||||
<!-- ===== Editor mode: reuse the same EditorPanel as note editor ===== -->
|
||||
<EditorPanel
|
||||
content={textEditorContent}
|
||||
title={textEditorFile.name}
|
||||
isMarkdown={textEditorIsMarkdown}
|
||||
loading={textEditorLoading}
|
||||
error={textEditorError}
|
||||
dirty={textEditorDirty}
|
||||
viewMode={textEditorMode}
|
||||
on:content-change={(e) => { textEditorContent = e.detail.content; textEditorDirty = true }}
|
||||
on:save={() => {
|
||||
if (textEditorIsNote) {
|
||||
wailsCall('SaveNote', textEditorNoteId, textEditorContent)
|
||||
} else {
|
||||
wailsCall('SaveFileText', textEditorFile.fileId, textEditorContent)
|
||||
}
|
||||
textEditorDirty = false
|
||||
}}
|
||||
on:close={closeTextEditor}
|
||||
on:openExternal={() => wailsCall('OpenFile', textEditorFile.fileId)}
|
||||
on:mode-change={(e) => textEditorMode = e.detail.mode}
|
||||
/>
|
||||
{:else}
|
||||
<!-- ===== Browser mode ===== -->
|
||||
<div class="tab-toolbar">
|
||||
<button class="btn btn-primary" on:click={_addFile} disabled={importing}>{t('file.addFile')}</button>
|
||||
<button class="btn" on:click={_addFolder} disabled={importing}>{t('file.addFolder')}</button>
|
||||
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
|
||||
{#if clipboard.items.length > 0}
|
||||
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadingFiles}
|
||||
<div class="empty-state">
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if folderStack.length > 0}
|
||||
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
|
||||
const i = e.detail
|
||||
if (i === 0) {
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
if (selectedNode) _loadFolder(selectedNode.id)
|
||||
} else {
|
||||
navigateToBreadcrumb(i - 1)
|
||||
}
|
||||
}}/>
|
||||
<button class="btn btn-sm back-btn" on:click={navigateBack}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
{t('common.backLabel')}
|
||||
</button>
|
||||
{:else}
|
||||
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
|
||||
{/if}
|
||||
|
||||
{#if fileItems.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p>
|
||||
<p class="hint">{t('file.hint')}</p>
|
||||
<div class="empty-actions">
|
||||
<button class="btn btn-primary" on:click={_addFile}>{t('file.addFileSimple')}</button>
|
||||
<button class="btn" on:click={_addFolder}>{t('file.addFolderSimple')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="file-list">
|
||||
{#each fileItems as item (item.id)}
|
||||
<FileTreeRow
|
||||
{item}
|
||||
selected={selectedIds.includes(item.id)}
|
||||
{onDragStart}
|
||||
{onDragOver}
|
||||
{onDrop}
|
||||
on:navigate={(e) => navigateToFolder(e.detail)}
|
||||
on:preview={(e) => _openFile(e.detail)}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
||||
on:delete={(e) => deleteFile(e.detail)}
|
||||
on:rename={(e) => renameItem(e.detail.id)}
|
||||
on:duplicate={(e) => duplicateItem(e.detail)}
|
||||
on:cut={(e) => cutItem(e.detail)}
|
||||
on:copy={(e) => copyItem(e.detail)}
|
||||
on:selectOne={(e) => selectOne(e.detail)}
|
||||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
||||
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if importing && !showImportDialog}
|
||||
<div class="empty-state"><p>{t('file.scanning')}</p></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Rename modal -->
|
||||
{#if showRename}
|
||||
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={(e) => e.key === 'Escape' && cancelRename()}>
|
||||
<div class="modal">
|
||||
<h3>{t('rename.title')}</h3>
|
||||
<div class="form-group">
|
||||
<label><span class="label-text">{t('common.newName')}</span>
|
||||
<input type="text" bind:value={renameValue}
|
||||
on:keydown={onRenameKeydown} />
|
||||
</label>
|
||||
</div>
|
||||
{#if renameError}
|
||||
<div class="rename-error">{renameError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={submitRename}>{t('common.rename')}</button>
|
||||
<button class="btn" on:click={cancelRename}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirm modal -->
|
||||
{#if showConfirm}
|
||||
<ConfirmModal
|
||||
title={confirmTitle}
|
||||
message={confirmMessage}
|
||||
confirmText={confirmText}
|
||||
danger={confirmDanger}
|
||||
on:confirm={handleConfirm}
|
||||
on:cancel={handleCancel}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Import dialog -->
|
||||
{#if showImportDialog && importSummary}
|
||||
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelImport} on:keydown={(e) => e.key === 'Escape' && cancelImport()}>
|
||||
<div class="modal">
|
||||
<h3>{t('file.importTitle')} «{selectedNode ? selectedNode.title : ''}»</h3>
|
||||
<div class="import-summary">
|
||||
<div class="summary-row"><span>{t('file.importFiles')}</span><span>{importSummary.files}</span></div>
|
||||
<div class="summary-row"><span>{t('file.importFolders')}</span><span>{importSummary.folders}</span></div>
|
||||
<div class="summary-row"><span>{t('file.importSize')}</span><span>{(importSummary.totalBytes / 1024).toFixed(1)} KB</span></div>
|
||||
{#if importSummary.isDangerous}
|
||||
<div class="summary-warn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
{importSummary.dangerReason}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>{t('file.importCopy')}</button>
|
||||
<button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button>
|
||||
<button class="btn" on:click={cancelImport}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- File preview modal (images, PDF, binary only) -->
|
||||
{#if previewItem}
|
||||
<FilePreviewModal
|
||||
item={previewItem}
|
||||
content={previewContent}
|
||||
loading={previewLoading}
|
||||
error={previewError}
|
||||
on:close={closePreview}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.files-tab { padding: 20px; }
|
||||
.files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
|
||||
.file-list { display: flex; flex-direction: column; }
|
||||
.back-btn { margin-bottom: 4px; display: inline-flex; align-items: center; gap: 4px; }
|
||||
|
||||
.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; }
|
||||
|
||||
/* 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:hover { background: #222233; }
|
||||
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
|
||||
.btn-primary:hover { background: #4f46e5; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
|
||||
.btn-danger:hover { background: #3a2222; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
<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>
|
||||
|
||||
<div class="markdown-body" on:click={handleClick} role="article">
|
||||
<div class="markdown-body" on:click={handleClick} role="article" tabindex="0" on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(e) }}>
|
||||
{#if error}
|
||||
<div class="md-error">
|
||||
<p>⚠️ {t('note.preview.error')}</p>
|
||||
|
|
|
|||
|
|
@ -1,188 +1,62 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||
import MarkdownPreview from './MarkdownPreview.svelte';
|
||||
import { t } from '../../i18n';
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import EditorPanel from './EditorPanel.svelte'
|
||||
import { t } from '../../i18n'
|
||||
|
||||
export let content = '';
|
||||
export let viewMode = 'edit';
|
||||
export let placeholder = '';
|
||||
// ===== Props (note context) =====
|
||||
export let content = ''
|
||||
export let viewMode = 'edit'
|
||||
export let placeholder = ''
|
||||
export let noteId = ''
|
||||
export let noteTitle = ''
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let activeEditor = undefined; // bind:this ref for the visible MarkdownEditor
|
||||
const dispatch = createEventDispatcher()
|
||||
let editorRef = undefined
|
||||
|
||||
function setMode(mode) {
|
||||
dispatch('mode-change', { mode });
|
||||
// ===== Public API =====
|
||||
export function insertText(text) {
|
||||
if (editorRef) editorRef.insertText(text)
|
||||
}
|
||||
|
||||
// ===== Handlers =====
|
||||
function handleContentChange(e) {
|
||||
content = e.detail.content;
|
||||
dispatch('content-change', e.detail);
|
||||
content = e.detail.content
|
||||
dispatch('content-change', e.detail)
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
dispatch('save');
|
||||
dispatch('save')
|
||||
}
|
||||
|
||||
function handleInsertLink() {
|
||||
dispatch('insert-link');
|
||||
dispatch('insert-link')
|
||||
}
|
||||
|
||||
function handleInsertInternalLink() {
|
||||
dispatch('insert-internal-link');
|
||||
dispatch('insert-internal-link')
|
||||
}
|
||||
|
||||
function handleVerstakLink(e) {
|
||||
dispatch('verstak-link', e.detail);
|
||||
dispatch('verstak-link', e.detail)
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────
|
||||
export function insertText(text) {
|
||||
if (activeEditor && typeof activeEditor.insertText === 'function') {
|
||||
activeEditor.insertText(text);
|
||||
}
|
||||
function handleModeChange(e) {
|
||||
viewMode = e.detail.mode
|
||||
dispatch('mode-change', e.detail)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="note-editor-panel" class:mode-edit={viewMode === 'edit'} class:mode-preview={viewMode === 'preview'} class:mode-split={viewMode === 'split'}>
|
||||
<!-- Mode switcher -->
|
||||
<div class="mode-switcher" role="tablist" aria-label="Note view mode">
|
||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'edit'} class:active={viewMode === 'edit'} on:click={() => setMode('edit')}>
|
||||
{t('note.mode.edit')}
|
||||
</button>
|
||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'preview'} class:active={viewMode === 'preview'} on:click={() => setMode('preview')}>
|
||||
{t('note.mode.preview')}
|
||||
</button>
|
||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'split'} class:active={viewMode === 'split'} on:click={() => setMode('split')}>
|
||||
{t('note.mode.split')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="panel-content">
|
||||
{#if viewMode === 'edit'}
|
||||
<MarkdownEditor
|
||||
bind:this={activeEditor}
|
||||
<EditorPanel
|
||||
bind:this={editorRef}
|
||||
{content}
|
||||
{placeholder}
|
||||
viewMode="edit"
|
||||
title={noteTitle}
|
||||
isMarkdown={true}
|
||||
{viewMode}
|
||||
on:content-change={handleContentChange}
|
||||
on:save={handleSave}
|
||||
on:mode-change={handleModeChange}
|
||||
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>
|
||||
on:verstak-link={handleVerstakLink}
|
||||
on:close={() => dispatch('close')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
// ===== Props =====
|
||||
export let notes = []
|
||||
export let formatDate = (str) => ''
|
||||
|
||||
// ===== Events =====
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// ===== Internal state =====
|
||||
let showCreateNote = false
|
||||
let newNoteTitle = ''
|
||||
|
||||
function openCreateNote() {
|
||||
showCreateNote = true
|
||||
newNoteTitle = ''
|
||||
}
|
||||
|
||||
function cancelCreateNote() {
|
||||
showCreateNote = false
|
||||
newNoteTitle = ''
|
||||
}
|
||||
|
||||
function submitCreateNote() {
|
||||
if (!newNoteTitle.trim()) return
|
||||
dispatch('submitCreateNote', { title: newNoteTitle.trim() })
|
||||
showCreateNote = false
|
||||
newNoteTitle = ''
|
||||
}
|
||||
|
||||
function handleCreateKeydown(e) {
|
||||
if (e.key === 'Enter') submitCreateNote()
|
||||
}
|
||||
|
||||
function openNote(note) {
|
||||
dispatch('openNote', { note })
|
||||
}
|
||||
|
||||
function startRename(noteId, currentTitle) {
|
||||
dispatch('startRename', { noteId, currentTitle })
|
||||
}
|
||||
|
||||
function deleteNoteHandler(note) {
|
||||
dispatch('deleteNote', { note })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="notes-tab">
|
||||
<div class="tab-toolbar">
|
||||
<button class="btn btn-primary" on:click={openCreateNote}>Добавить заметку</button>
|
||||
</div>
|
||||
|
||||
{#if showCreateNote}
|
||||
<div class="create-form">
|
||||
<input type="text" placeholder="Название заметки" bind:value={newNoteTitle}
|
||||
on:keydown={handleCreateKeydown} />
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" on:click={submitCreateNote}>Создать</button>
|
||||
<button class="btn" on:click={cancelCreateNote}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if notes.length === 0 && !showCreateNote}
|
||||
<div class="empty-state"><p>Нет заметок</p><p class="hint">Создайте первую заметку</p></div>
|
||||
{:else if notes.length > 0}
|
||||
<div class="notes-list">
|
||||
{#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-info">
|
||||
<div class="note-card-title">{note.title}</div>
|
||||
<div class="note-card-date">{formatDate(note.createdAt)}</div>
|
||||
</div>
|
||||
<div class="note-card-actions" on:click|stopPropagation>
|
||||
<button class="note-action-btn" on:click={() => startRename(note.id, note.title)} title="Переименовать">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
|
||||
</button>
|
||||
<button class="note-action-btn note-action-danger" on:click={() => deleteNoteHandler(note)} title="Удалить">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notes-tab { padding: 24px; }
|
||||
.tab-toolbar { margin-bottom: 16px; }
|
||||
.create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
|
||||
.create-form input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 8px; }
|
||||
.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; position: relative; }
|
||||
.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-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:hover .note-card-actions { opacity: 1; }
|
||||
.note-action-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: 4px; background: transparent; color: #666; cursor: pointer; transition: background 0.12s, color 0.12s; }
|
||||
.note-action-btn:hover { background: #2a2a3c; color: #ccc; }
|
||||
.note-action-danger:hover { background: rgba(239, 68, 68, 0.15); color: #f87171; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
|
||||
.empty-state p { margin: 0; font-size: 14px; color: #666; }
|
||||
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
|
||||
|
||||
/* 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:hover { background: #222233; }
|
||||
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
|
||||
.btn-primary:hover { background: #4f46e5; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
|
||||
.btn-danger:hover { background: #3a2222; }
|
||||
</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 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','sh','py','js','ts','css','html','md','markdown','cfg']
|
||||
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 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 imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,48 @@
|
|||
import App from './App.svelte'
|
||||
|
||||
new App({
|
||||
target: document.getElementById('app')
|
||||
// ===== Global frontend error diagnostics =====
|
||||
// These catch runtime errors that would otherwise cause a silent blank window.
|
||||
|
||||
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,5 +243,25 @@ export function CountActivityByNode(arg1) {
|
|||
}
|
||||
|
||||
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,6 +330,28 @@ func (s *Service) ReadText(id string) (string, error) {
|
|||
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).
|
||||
func (s *Service) ReadBase64(id string) (string, error) {
|
||||
rec, err := s.Get(id)
|
||||
|
|
|
|||
Loading…
Reference in New Issue