Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
120ff7d6fe | ||
|
|
383a9546df | ||
|
|
d7d806530b |
@@ -210,6 +210,46 @@ 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
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
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-WcQPBFFT.js"></script>
|
<script type="module" crossorigin src="/assets/main-B99YW--H.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-DKrNFx34.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-CtgLvi_n.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
import FileTreeRow from '../../../FileTreeRow.svelte'
|
import FileTreeRow from '../../../FileTreeRow.svelte'
|
||||||
import FilePreviewModal from '../../FilePreviewModal.svelte'
|
import FilePreviewModal from '../../FilePreviewModal.svelte'
|
||||||
import ConfirmModal from '../../ConfirmModal.svelte'
|
import ConfirmModal from '../../ConfirmModal.svelte'
|
||||||
import { canPreviewFile, needsBase64Preview, needsTextPreview, isMarkdownFile } from '../../fileUtils.js'
|
import { canPreviewFile, needsBase64Preview, isTextFile, isMarkdownFile } from '../../fileUtils.js'
|
||||||
import { t } from '../../i18n'
|
import { t } from '../../i18n'
|
||||||
|
import EditorPanel from '../notes/EditorPanel.svelte'
|
||||||
|
|
||||||
// ===== Props =====
|
// ===== Props =====
|
||||||
export let selectedNode = null
|
export let selectedNode = null
|
||||||
@@ -24,10 +25,21 @@
|
|||||||
let currentFolderId = null
|
let currentFolderId = null
|
||||||
let folderStack = []
|
let folderStack = []
|
||||||
let fileItems = []
|
let fileItems = []
|
||||||
|
// Preview modal state (images, PDF, binary)
|
||||||
let previewItem = null
|
let previewItem = null
|
||||||
let previewContent = ''
|
let previewContent = ''
|
||||||
let previewLoading = false
|
let previewLoading = false
|
||||||
let previewError = ''
|
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 clipboard = { items: [], mode: 'copy' }
|
||||||
let selectedIds = []
|
let selectedIds = []
|
||||||
let dragIds = []
|
let dragIds = []
|
||||||
@@ -56,6 +68,7 @@
|
|||||||
dragIds = []
|
dragIds = []
|
||||||
previewItem = null
|
previewItem = null
|
||||||
previewContent = ''
|
previewContent = ''
|
||||||
|
closeTextEditor()
|
||||||
loadFolder(nodeId)
|
loadFolder(nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +92,7 @@
|
|||||||
// Find the file in the current fileItems and open preview
|
// Find the file in the current fileItems and open preview
|
||||||
const fileItem = fileItems.find(f => f.id === fileNodeId)
|
const fileItem = fileItems.find(f => f.id === fileNodeId)
|
||||||
if (fileItem) {
|
if (fileItem) {
|
||||||
_openPreview(fileItem)
|
_openFile(fileItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,32 +152,84 @@
|
|||||||
_loadFolder(target.id)
|
_loadFolder(target.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== File preview =====
|
// ===== Open file: text editor or preview modal =====
|
||||||
async function _openPreview(item) {
|
async function _openFile(item) {
|
||||||
_fdl('openPreview item=' + JSON.stringify({id: item.id, name: item.name, type: item.type, fileId: item.fileId}))
|
_fdl('openFile item=' + JSON.stringify({id: item.id, name: item.name, type: item.type, fileId: item.fileId}))
|
||||||
// For .md files: check if linked to a note, open note editor instead
|
|
||||||
|
// 1. Markdown: check if linked to a note
|
||||||
if (item && item.fileId && isMarkdownFile(item)) {
|
if (item && item.fileId && isMarkdownFile(item)) {
|
||||||
_fdl('isMarkdown=true fileId=' + item.fileId)
|
|
||||||
try {
|
try {
|
||||||
const action = await wailsCall('CheckFileAction', item.fileId)
|
const action = await wailsCall('CheckFileAction', item.fileId)
|
||||||
_fdl('CheckFileAction result=' + JSON.stringify(action))
|
_fdl('CheckFileAction result=' + JSON.stringify(action))
|
||||||
if (action.action === 'note') {
|
if (action && action.action === 'note') {
|
||||||
|
// Linked note → open note editor
|
||||||
dispatch('openNote', { id: action.noteId, title: action.noteTitle })
|
dispatch('openNote', { id: action.noteId, title: action.noteTitle })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action.action === 'external') {
|
// action === 'preview' → fall through to text editor below
|
||||||
await wailsCall('OpenFile', item.fileId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// action === 'preview' → fall through to built-in preview below
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_fdl('CheckFileAction ERROR: ' + String(e))
|
_fdl('CheckFileAction ERROR: ' + String(e))
|
||||||
console.warn('CheckFileAction failed, falling back to preview:', e)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
_fdl('isMarkdown=false or no fileId, type=' + item.type)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
previewItem = item
|
||||||
previewContent = ''
|
previewContent = ''
|
||||||
previewError = ''
|
previewError = ''
|
||||||
@@ -172,12 +237,6 @@
|
|||||||
try {
|
try {
|
||||||
if (needsBase64Preview(item)) {
|
if (needsBase64Preview(item)) {
|
||||||
previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
|
previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
|
||||||
} else if (needsTextPreview(item)) {
|
|
||||||
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
|
|
||||||
} else if (item.fileId && item.type !== 'folder') {
|
|
||||||
// Fallback: try to read as text for unknown types
|
|
||||||
_fdl('fallback text read for item=' + item.name + ' mime=' + item.mime)
|
|
||||||
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
previewError = String(e)
|
previewError = String(e)
|
||||||
@@ -387,7 +446,7 @@
|
|||||||
if (item.type === 'folder') {
|
if (item.type === 'folder') {
|
||||||
navigateToFolder(item.id)
|
navigateToFolder(item.id)
|
||||||
} else {
|
} else {
|
||||||
_openPreview(item)
|
_openFile(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,9 +622,11 @@
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
openSelected()
|
openSelected()
|
||||||
} else if (e.key === 'Delete') {
|
} else if (e.key === 'Delete') {
|
||||||
|
if (textEditorFile) { e.preventDefault(); closeTextEditor(); return }
|
||||||
if (previewItem) { e.preventDefault(); closePreview(); return }
|
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
|
if (textEditorFile) { closeTextEditor(); return }
|
||||||
if (previewItem) { closePreview(); return }
|
if (previewItem) { closePreview(); return }
|
||||||
if (selectedIds.length > 0) { clearSelection(); return }
|
if (selectedIds.length > 0) { clearSelection(); return }
|
||||||
} else if (e.key === 'F2') {
|
} else if (e.key === 'F2') {
|
||||||
@@ -595,87 +656,113 @@
|
|||||||
|
|
||||||
<!-- Files tab template -->
|
<!-- Files tab template -->
|
||||||
<div class="files-tab">
|
<div class="files-tab">
|
||||||
<div class="tab-toolbar">
|
{#if textEditorFile}
|
||||||
<button class="btn btn-primary" on:click={_addFile} disabled={importing}>{t('file.addFile')}</button>
|
<!-- ===== Editor mode: reuse the same EditorPanel as note editor ===== -->
|
||||||
<button class="btn" on:click={_addFolder} disabled={importing}>{t('file.addFolder')}</button>
|
<EditorPanel
|
||||||
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
|
content={textEditorContent}
|
||||||
{#if clipboard.items.length > 0}
|
title={textEditorFile.name}
|
||||||
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
|
isMarkdown={textEditorIsMarkdown}
|
||||||
{/if}
|
loading={textEditorLoading}
|
||||||
</div>
|
error={textEditorError}
|
||||||
|
dirty={textEditorDirty}
|
||||||
{#if loadingFiles}
|
viewMode={textEditorMode}
|
||||||
<div class="empty-state">
|
on:content-change={(e) => { textEditorContent = e.detail.content; textEditorDirty = true }}
|
||||||
<p>{t('common.loading')}</p>
|
on:save={() => {
|
||||||
</div>
|
if (textEditorIsNote) {
|
||||||
{:else}
|
wailsCall('SaveNote', textEditorNoteId, textEditorContent)
|
||||||
{#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 {
|
} else {
|
||||||
navigateToBreadcrumb(i - 1)
|
wailsCall('SaveFileText', textEditorFile.fileId, textEditorContent)
|
||||||
}
|
}
|
||||||
}}/>
|
textEditorDirty = false
|
||||||
<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>
|
on:close={closeTextEditor}
|
||||||
{t('common.backLabel')}
|
on:openExternal={() => wailsCall('OpenFile', textEditorFile.fileId)}
|
||||||
</button>
|
on:mode-change={(e) => textEditorMode = e.detail.mode}
|
||||||
{:else}
|
/>
|
||||||
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
|
{:else}
|
||||||
{/if}
|
<!-- ===== 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 fileItems.length === 0}
|
{#if loadingFiles}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">
|
<p>{t('common.loading')}</p>
|
||||||
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="file-list">
|
{#if folderStack.length > 0}
|
||||||
{#each fileItems as item (item.id)}
|
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
|
||||||
<FileTreeRow
|
const i = e.detail
|
||||||
{item}
|
if (i === 0) {
|
||||||
selected={selectedIds.includes(item.id)}
|
folderStack = []
|
||||||
{onDragStart}
|
currentFolderId = null
|
||||||
{onDragOver}
|
if (selectedNode) _loadFolder(selectedNode.id)
|
||||||
{onDrop}
|
} else {
|
||||||
on:navigate={(e) => navigateToFolder(e.detail)}
|
navigateToBreadcrumb(i - 1)
|
||||||
on:preview={(e) => _openPreview(e.detail)}
|
}
|
||||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
}}/>
|
||||||
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
<button class="btn btn-sm back-btn" on:click={navigateBack}>
|
||||||
on:delete={(e) => deleteFile(e.detail)}
|
<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>
|
||||||
on:rename={(e) => renameItem(e.detail.id)}
|
{t('common.backLabel')}
|
||||||
on:duplicate={(e) => duplicateItem(e.detail)}
|
</button>
|
||||||
on:cut={(e) => cutItem(e.detail)}
|
{:else}
|
||||||
on:copy={(e) => copyItem(e.detail)}
|
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
|
||||||
on:selectOne={(e) => selectOne(e.detail)}
|
{/if}
|
||||||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
|
||||||
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if importing && !showImportDialog}
|
{#if fileItems.length === 0}
|
||||||
<div class="empty-state"><p>{t('file.scanning')}</p></div>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rename modal (inline in FilesTab) -->
|
<!-- Rename modal -->
|
||||||
{#if showRename}
|
{#if showRename}
|
||||||
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={(e) => e.key === 'Escape' && cancelRename()}>
|
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={(e) => e.key === 'Escape' && cancelRename()}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -697,7 +784,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Confirm modal (inline in FilesTab) -->
|
<!-- Confirm modal -->
|
||||||
{#if showConfirm}
|
{#if showConfirm}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title={confirmTitle}
|
title={confirmTitle}
|
||||||
@@ -709,7 +796,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Import dialog (inline in FilesTab) -->
|
<!-- Import dialog -->
|
||||||
{#if showImportDialog && importSummary}
|
{#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-overlay" role="button" tabindex="0" on:click|self={cancelImport} on:keydown={(e) => e.key === 'Escape' && cancelImport()}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -734,7 +821,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- File preview modal (inline in FilesTab) -->
|
<!-- File preview modal (images, PDF, binary only) -->
|
||||||
{#if previewItem}
|
{#if previewItem}
|
||||||
<FilePreviewModal
|
<FilePreviewModal
|
||||||
item={previewItem}
|
item={previewItem}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,188 +1,62 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte'
|
||||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
import EditorPanel from './EditorPanel.svelte'
|
||||||
import MarkdownPreview from './MarkdownPreview.svelte';
|
import { t } from '../../i18n'
|
||||||
import { t } from '../../i18n';
|
|
||||||
|
|
||||||
export let content = '';
|
// ===== Props (note context) =====
|
||||||
export let viewMode = 'edit';
|
export let content = ''
|
||||||
export let placeholder = '';
|
export let viewMode = 'edit'
|
||||||
|
export let placeholder = ''
|
||||||
|
export let noteId = ''
|
||||||
|
export let noteTitle = ''
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher()
|
||||||
let activeEditor = undefined; // bind:this ref for the visible MarkdownEditor
|
let editorRef = undefined
|
||||||
|
|
||||||
function setMode(mode) {
|
// ===== Public API =====
|
||||||
dispatch('mode-change', { mode });
|
export function insertText(text) {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Public API ──────────────────────────────────────────────────
|
function handleModeChange(e) {
|
||||||
export function insertText(text) {
|
viewMode = e.detail.mode
|
||||||
if (activeEditor && typeof activeEditor.insertText === 'function') {
|
dispatch('mode-change', e.detail)
|
||||||
activeEditor.insertText(text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="note-editor-panel" class:mode-edit={viewMode === 'edit'} class:mode-preview={viewMode === 'preview'} class:mode-split={viewMode === 'split'}>
|
<EditorPanel
|
||||||
<!-- Mode switcher -->
|
bind:this={editorRef}
|
||||||
<div class="mode-switcher" role="tablist" aria-label="Note view mode">
|
{content}
|
||||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'edit'} class:active={viewMode === 'edit'} on:click={() => setMode('edit')}>
|
title={noteTitle}
|
||||||
{t('note.mode.edit')}
|
isMarkdown={true}
|
||||||
</button>
|
{viewMode}
|
||||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'preview'} class:active={viewMode === 'preview'} on:click={() => setMode('preview')}>
|
on:content-change={handleContentChange}
|
||||||
{t('note.mode.preview')}
|
on:save={handleSave}
|
||||||
</button>
|
on:mode-change={handleModeChange}
|
||||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'split'} class:active={viewMode === 'split'} on:click={() => setMode('split')}>
|
on:insert-link={handleInsertLink}
|
||||||
{t('note.mode.split')}
|
on:insert-internal-link={handleInsertInternalLink}
|
||||||
</button>
|
on:verstak-link={handleVerstakLink}
|
||||||
</div>
|
on:close={() => dispatch('close')}
|
||||||
|
/>
|
||||||
<!-- 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>
|
|
||||||
|
|||||||
@@ -330,6 +330,28 @@ 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