f46f57eded
- Drafts saved 1s after last keystroke (debounced) - 'Draft saved' indicator in toolbar - On file open: prompts to restore unsaved draft if found - Draft cleared on successful save (Ctrl+S) - Works across disconnects/logouts — draft persists in browser
1661 lines
57 KiB
Vue
1661 lines
57 KiB
Vue
<template>
|
||
<!-- Toast notifications -->
|
||
<div class="toast-container">
|
||
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.msg }}</div>
|
||
</div>
|
||
<div class="app" v-if="authenticated">
|
||
<button class="hamburger" @click="sidebarOpen = !sidebarOpen">☰</button>
|
||
<aside class="sidebar" :class="{open: sidebarOpen}">
|
||
<div class="sidebar-header">
|
||
<h2>MarkdownHub</h2>
|
||
<div class="sidebar-actions">
|
||
<button @click="toggleTheme" title="Toggle theme">{{ dark ? '☀️' : '🌙' }}</button>
|
||
<button @click="createFolder" title="New folder">📁+</button>
|
||
<button @click="createFile" title="New file">📄+</button>
|
||
</div>
|
||
</div>
|
||
<div class="search-box">
|
||
<input v-model="searchQuery" placeholder="Search files..." @input="filterFiles" />
|
||
</div>
|
||
<div class="sidebar-nav">
|
||
<button :class="{active: view === 'files'}" @click="view = 'files'">📄 Files</button>
|
||
<button :class="{active: view === 'shared'}" @click="view = 'shared'">🤝 Shared</button>
|
||
<button :class="{active: view === 'trash'}" @click="view = 'trash'; loadTrash()" @dragover.prevent @drop.prevent="dropToTrash($event)">🗑️ Trash</button>
|
||
</div>
|
||
<div class="sidebar-nav">
|
||
<button :class="{active: view === 'prefs'}" @click="view = 'prefs'">⚙️</button>
|
||
<button v-if="isAdmin" :class="{active: view === 'admin'}" @click="view = 'admin'">👤</button>
|
||
<button @click="logout" title="Logout">🚪</button>
|
||
<button @click="view = 'about'" title="About">ℹ️</button>
|
||
</div>
|
||
<div v-if="view === 'files'" class="sort-bar">
|
||
<button @click="sortFiles('name')" :class="{active: sortBy === 'name'}">Name</button>
|
||
<button @click="sortFiles('date')" :class="{active: sortBy === 'date'}">Date</button>
|
||
</div>
|
||
<div v-if="loading" class="loading-bar"></div>
|
||
<FileTree v-if="view === 'files'" :files="filteredFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" @move="moveFile" @rename="renameFile" />
|
||
<FileTree v-if="view === 'shared'" :files="sharedFiles" :selected="currentFile" @select="openSharedFile" @delete="deleteItem" @move="moveFile" />
|
||
<p v-if="view === 'shared' && !sharedFiles.length" style="padding:16px;color:var(--text-muted);font-size:13px;text-align:center">No files shared with you yet</p>
|
||
<p v-if="view === 'shared' && sharedFiles.length" style="padding:4px 12px;color:var(--text-muted);font-size:11px">Click a file to open it</p>
|
||
<div v-if="view === 'trash'" class="trash-view">
|
||
<div class="trash-header">
|
||
<span>{{ trashItems.length }} item(s)</span>
|
||
<button v-if="trashItems.length" @click="emptyTrash" class="empty-trash-btn">Empty Trash</button>
|
||
</div>
|
||
<div v-for="item in trashItems" :key="item.name" class="trash-item">
|
||
<span>{{ item.isDir ? '📁' : '📄' }} {{ item.name }}</span>
|
||
<button @click="restoreTrash(item.name)" title="Restore">↩️</button>
|
||
</div>
|
||
<p v-if="!trashItems.length" class="trash-empty">Trash is empty</p>
|
||
</div>
|
||
</aside>
|
||
<main class="editor-area" v-if="view === 'files' || view === 'shared'">
|
||
<div class="toolbar">
|
||
<div class="mode-switcher">
|
||
<button :class="{active: mode === 'wysiwyg'}" @click="mode = 'wysiwyg'">Full Page</button>
|
||
<button :class="{active: mode === 'raw'}" @click="mode = 'raw'">Raw</button>
|
||
<button :class="{active: mode === 'split'}" @click="mode = 'split'">Split</button>
|
||
</div>
|
||
<div class="format-toolbar" v-if="currentFile && mode !== 'wysiwyg'">
|
||
<button @click="insertFormat('**', '**')" title="Bold (Ctrl+B)"><b>B</b></button>
|
||
<button @click="insertFormat('*', '*')" title="Italic (Ctrl+I)"><i>I</i></button>
|
||
<button @click="insertFormat('~~', '~~')" title="Strikethrough"><s>S</s></button>
|
||
<button @click="insertPrefix('# ')" title="H1">H1</button>
|
||
<button @click="insertPrefix('## ')" title="H2">H2</button>
|
||
<button @click="insertPrefix('### ')" title="H3">H3</button>
|
||
<button @click="insertPrefix('- ')" title="List">•</button>
|
||
<button @click="insertPrefix('1. ')" title="Numbered list">1.</button>
|
||
<button @click="insertPrefix('- [ ] ')" title="Checkbox">☐</button>
|
||
<button @click="insertPrefix('> ')" title="Quote">❝</button>
|
||
<button @click="insertFormat('`', '`')" title="Inline code (Ctrl+`)"></></button>
|
||
<button @click="insertCodeBlock()" title="Code block">```</button>
|
||
<button @click="insertFormat('[', '](url)')" title="Link (Ctrl+K)">🔗</button>
|
||
<button @click="insertPrefix('---\n')" title="Horizontal rule">―</button>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<span class="file-name">{{ currentFile || 'No file open' }}</span>
|
||
<span v-if="draftSaved" class="draft-indicator">Draft saved</span>
|
||
<span class="file-meta" v-if="fileMeta">{{ fileMeta }}</span>
|
||
<button v-if="currentFile" class="toolbar-btn" @click="toggleCollab" :title="collabActive ? 'Disconnect collab' : 'Enable collab'">
|
||
{{ collabActive ? '👥 Live' : '👤 Solo' }}
|
||
</button>
|
||
<button v-if="currentFile" class="toolbar-btn" @click="showHistory = !showHistory" title="History">📜</button>
|
||
<button v-if="currentFile" class="toolbar-btn" @click="showShareDialog = !showShareDialog" title="Share">🤝</button>
|
||
<button v-if="currentFile" class="toolbar-btn" @click="aiVerify" title="Verify with AI">🤖 Verify</button>
|
||
<button class="git-btn" :class="{dirty: gitDirty > 0}" @click="gitSync" :title="gitDirty > 0 ? gitDirty + ' uncommitted changes' : 'All synced'">
|
||
{{ gitDirty > 0 ? '🔴' : '🟢' }} Git
|
||
</button>
|
||
<div class="export-actions" v-if="currentFile">
|
||
<button @click="exportPDF" title="Export PDF">PDF</button>
|
||
<button @click="exportHTML" title="Export HTML">HTML</button>
|
||
<button @click="exportMD" title="Download .md">MD</button>
|
||
</div>
|
||
<button class="save-btn" :class="{dirty: isDirty || (!currentFile && content)}" @click="saveFile">
|
||
{{ !currentFile && content ? 'Save as...' : isDirty ? 'Save*' : 'Saved' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="editor-container" :class="mode">
|
||
<div v-if="mode === 'raw' || mode === 'split'" class="raw-pane">
|
||
<textarea
|
||
ref="editor"
|
||
v-model="content"
|
||
@input="onEdit"
|
||
@keydown="handleKeydown"
|
||
@drop.prevent="handleImageDrop"
|
||
@dragover.prevent
|
||
placeholder="Start writing markdown..."
|
||
></textarea>
|
||
</div>
|
||
<div v-if="mode === 'wysiwyg'" class="wysiwyg-pane">
|
||
<MilkdownEditor
|
||
:key="currentFile"
|
||
v-model="content"
|
||
@update:modelValue="onEdit"
|
||
/>
|
||
</div>
|
||
<div v-if="mode === 'split'" class="preview-pane">
|
||
<div v-if="rendered" v-html="rendered"></div>
|
||
<p v-else style="color:#6c7086;font-style:italic">Preview will appear here...</p>
|
||
</div>
|
||
</div>
|
||
<!-- History Panel -->
|
||
<div class="history-panel" v-if="showHistory">
|
||
<div class="panel-header">
|
||
<h3>History: {{ currentFile }}</h3>
|
||
<button @click="showHistory = false">✕</button>
|
||
</div>
|
||
<div class="history-list">
|
||
<div v-for="c in history" :key="c.hash" class="history-item" @click="restoreVersion(c.hash)">
|
||
<span class="history-hash">{{ c.hash.slice(0, 7) }}</span>
|
||
<span class="history-msg">{{ c.message }}</span>
|
||
<span class="history-date">{{ c.date }}</span>
|
||
</div>
|
||
<p v-if="!history.length" style="color:var(--text-muted);padding:12px">No history yet</p>
|
||
</div>
|
||
</div>
|
||
<!-- Share Dialog -->
|
||
<div class="share-dialog" v-if="showShareDialog">
|
||
<div class="panel-header">
|
||
<h3>Share: {{ currentFile }}</h3>
|
||
<button @click="showShareDialog = false">✕</button>
|
||
</div>
|
||
<form @submit.prevent="shareFile" class="share-form">
|
||
<input v-model="shareUsername" placeholder="Username to share with" />
|
||
<select v-model="shareLevel">
|
||
<option value="ro">Read only</option>
|
||
<option value="rw">Read & Write</option>
|
||
</select>
|
||
<button type="submit">Share</button>
|
||
</form>
|
||
<p v-if="shareMsg" class="share-msg">{{ shareMsg }}</p>
|
||
</div>
|
||
<!-- AI Panel -->
|
||
<div class="ai-panel" v-if="aiResult">
|
||
<div class="panel-header">
|
||
<h3>AI Response</h3>
|
||
<button @click="aiResult = ''">✕</button>
|
||
</div>
|
||
<div class="ai-content" v-html="renderMarkdown(aiResult)"></div>
|
||
</div>
|
||
</main>
|
||
<main class="panel" v-if="view === 'prefs'">
|
||
<h2>Preferences</h2>
|
||
<div class="panel-section">
|
||
<label>Timezone</label>
|
||
<select v-model="prefs.timezone" @change="savePrefs">
|
||
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="panel-section">
|
||
<label>Default editor mode</label>
|
||
<select v-model="prefs.defaultMode" @change="savePrefs">
|
||
<option value="split">Split</option>
|
||
<option value="raw">Raw</option>
|
||
<option value="wysiwyg">WYSIWYG</option>
|
||
</select>
|
||
</div>
|
||
<div class="panel-section">
|
||
<label>Theme</label>
|
||
<select v-model="prefs.theme" @change="applyThemeFromPrefs">
|
||
<option value="dark">Dark</option>
|
||
<option value="light">Light</option>
|
||
</select>
|
||
</div>
|
||
|
||
<h3>Change Password</h3>
|
||
<div class="panel-section">
|
||
<form @submit.prevent="changePassword" class="admin-form">
|
||
<input v-model="pwCurrent" type="password" placeholder="Current password" required />
|
||
<input v-model="pwNew" type="password" placeholder="New password" required />
|
||
<button type="submit">Change</button>
|
||
</form>
|
||
<p v-if="pwMsg" class="admin-msg">{{ pwMsg }}</p>
|
||
</div>
|
||
|
||
<h3>Two-Factor Authentication</h3>
|
||
<div class="panel-section" v-if="!totpEnabled">
|
||
<button @click="setupTOTP" class="action-btn">Enable 2FA</button>
|
||
<div v-if="totpUri" class="totp-setup">
|
||
<p>Scan this with your authenticator app:</p>
|
||
<code class="totp-secret">{{ totpSecret }}</code>
|
||
<p style="margin-top:8px">URI: <code style="font-size:11px;word-break:break-all">{{ totpUri }}</code></p>
|
||
<form @submit.prevent="verifyTOTP" class="totp-verify">
|
||
<input v-model="totpCode" placeholder="Enter 6-digit code" maxlength="6" />
|
||
<button type="submit">Verify & Enable</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<div class="panel-section" v-else>
|
||
<p>✅ 2FA is enabled</p>
|
||
<form @submit.prevent="disableTOTP" class="totp-verify">
|
||
<input v-model="totpCode" placeholder="Enter code to disable" maxlength="6" />
|
||
<button type="submit" style="background:var(--danger)">Disable 2FA</button>
|
||
</form>
|
||
</div>
|
||
|
||
<h3>Git Remotes</h3>
|
||
<div class="panel-section">
|
||
<div v-for="r in gitRemotes" :key="r.name" class="remote-item">
|
||
<span>{{ r.name }}</span>
|
||
<code>{{ r.url }}</code>
|
||
</div>
|
||
<p v-if="!gitRemotes.length" style="color:var(--text-muted)">No remotes configured</p>
|
||
<form @submit.prevent="addRemote" class="remote-form">
|
||
<input v-model="newRemote.name" placeholder="Name (e.g. gitea)" required />
|
||
<input v-model="newRemote.url" placeholder="https://git.example.com/user/repo.git" required />
|
||
<button type="submit">Add Remote</button>
|
||
</form>
|
||
<div v-if="gitRemotes.length" class="remote-actions">
|
||
<button @click="gitPush" class="action-btn">Push</button>
|
||
<button @click="gitPull" class="action-btn">Pull</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
<!-- Admin Panel -->
|
||
<main class="panel" v-if="view === 'admin' && isAdmin">
|
||
<h2>Admin — User Management</h2>
|
||
<div class="panel-section">
|
||
<h3>Create User</h3>
|
||
<form @submit.prevent="adminCreateUser" class="admin-form">
|
||
<input v-model="newUser.username" placeholder="Username" required />
|
||
<input v-model="newUser.email" placeholder="Email" required />
|
||
<input v-model="newUser.password" type="password" placeholder="Password" required />
|
||
<label><input type="checkbox" v-model="newUser.isAdmin" /> Admin</label>
|
||
<button type="submit">Create User</button>
|
||
</form>
|
||
<p v-if="adminMsg" class="admin-msg">{{ adminMsg }}</p>
|
||
</div>
|
||
<div class="panel-section">
|
||
<h3>Users</h3>
|
||
<table class="user-table">
|
||
<thead><tr><th>Username</th><th>Email</th><th>Admin</th><th>Created</th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="u in users" :key="u.id">
|
||
<td>{{ u.username }}</td>
|
||
<td>{{ u.email }}</td>
|
||
<td>{{ u.isAdmin ? '✓' : '' }}</td>
|
||
<td>{{ formatDate(u.createdAt) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="panel-section">
|
||
<h3>LDAP / SLDAP Authentication</h3>
|
||
<form @submit.prevent="saveLdapSettings" class="admin-form">
|
||
<label>Server URL</label>
|
||
<input v-model="ldapSettings.ldap_url" placeholder="ldap://host:389 or ldaps://host:636" />
|
||
<label>Bind DN</label>
|
||
<input v-model="ldapSettings.ldap_bind_dn" placeholder="cn=service,dc=example,dc=com" />
|
||
<label>Bind Password</label>
|
||
<input v-model="ldapSettings.ldap_bind_pass" type="password" placeholder="Service account password" />
|
||
<label>Base DN</label>
|
||
<input v-model="ldapSettings.ldap_base_dn" placeholder="dc=example,dc=com" />
|
||
<label>User Filter</label>
|
||
<input v-model="ldapSettings.ldap_user_filter" placeholder="(&(objectClass=inetOrgPerson)(uid=%s))" />
|
||
<label>Required Group (DN or CN)</label>
|
||
<input v-model="ldapSettings.ldap_group_filter" placeholder="cn=markdownhub-users,ou=groups,dc=..." />
|
||
<label><input type="checkbox" v-model="ldapSkipTLS" /> Skip TLS verification</label>
|
||
<button type="submit">Save LDAP Settings</button>
|
||
</form>
|
||
<p v-if="ldapMsg" class="admin-msg">{{ ldapMsg }}</p>
|
||
</div>
|
||
</main>
|
||
<!-- About -->
|
||
<main class="panel" v-if="view === 'about'" style="text-align:center;padding-top:80px">
|
||
<h2>MarkdownHub</h2>
|
||
<p style="margin:16px 0"><a href="https://git.aholck.net/anders-pub/markdown-hub" target="_blank" rel="noopener">https://git.aholck.net/anders-pub/markdown-hub</a></p>
|
||
<p style="color:var(--text-muted)">Anders Holck 2026</p>
|
||
</main>
|
||
<!-- Shortcuts overlay -->
|
||
<div class="overlay" v-if="showShortcuts" @click="showShortcuts = false">
|
||
<div class="overlay-content" @click.stop>
|
||
<h3>Keyboard Shortcuts</h3>
|
||
<table><tbody>
|
||
<tr><td><kbd>Ctrl+S</kbd></td><td>Save</td></tr>
|
||
<tr><td><kbd>Ctrl+B</kbd></td><td>Bold</td></tr>
|
||
<tr><td><kbd>Ctrl+I</kbd></td><td>Italic</td></tr>
|
||
<tr><td><kbd>Ctrl+K</kbd></td><td>Link</td></tr>
|
||
<tr><td><kbd>Ctrl+`</kbd></td><td>Inline code</td></tr>
|
||
<tr><td><kbd>Tab</kbd></td><td>Indent</td></tr>
|
||
<tr><td><kbd>Ctrl+/</kbd></td><td>This help</td></tr>
|
||
</tbody></table>
|
||
<button @click="showShortcuts = false" style="margin-top:12px">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="login" v-else>
|
||
<form @submit.prevent="login">
|
||
<h1>MarkdownHub</h1>
|
||
<input v-model="email" type="email" placeholder="Email" required />
|
||
<input v-model="password" type="password" placeholder="Password" required />
|
||
<button type="submit">Login</button>
|
||
<p v-if="loginError" class="error">{{ loginError }}</p>
|
||
</form>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch } from 'vue'
|
||
import FileTree from './components/FileTree.vue'
|
||
import MilkdownEditor from './components/MilkdownEditor.vue'
|
||
import { api, setToken } from './lib/api.js'
|
||
import { renderMarkdown } from './lib/markdown.js'
|
||
import { cacheFile, getCachedFile, addPendingChange, getPendingChanges, clearAllPending } from './lib/offline.js'
|
||
import { connectCollab, disconnectCollab, setCollabContent } from './lib/collab.js'
|
||
|
||
const authenticated = ref(false)
|
||
const email = ref('')
|
||
const password = ref('')
|
||
const loginError = ref('')
|
||
const token = ref('')
|
||
|
||
const fileTree = ref([])
|
||
const filteredFiles = ref([])
|
||
const searchQuery = ref('')
|
||
const currentFile = ref('')
|
||
const content = ref('')
|
||
const isDirty = ref(false)
|
||
const mode = ref('split')
|
||
const editor = ref(null)
|
||
const dark = ref(true)
|
||
const view = ref('files')
|
||
const isAdmin = ref(false)
|
||
const fileMeta = ref('')
|
||
const sharedFiles = ref([])
|
||
const showHistory = ref(false)
|
||
const history = ref([])
|
||
const showShareDialog = ref(false)
|
||
const shareUsername = ref('')
|
||
const shareLevel = ref('ro')
|
||
const shareMsg = ref('')
|
||
const gitDirty = ref(0)
|
||
const aiResult = ref('')
|
||
const trashItems = ref([])
|
||
const collabActive = ref(false)
|
||
const sidebarOpen = ref(false)
|
||
const toasts = ref([])
|
||
const showShortcuts = ref(false)
|
||
const loading = ref(false)
|
||
const sortBy = ref('name')
|
||
|
||
let toastId = 0
|
||
function toast(msg, type = 'info') {
|
||
const id = ++toastId
|
||
toasts.value.push({ id, msg, type })
|
||
setTimeout(() => { toasts.value = toasts.value.filter(t => t.id !== id) }, 3000)
|
||
}
|
||
|
||
// beforeunload warning
|
||
window.addEventListener('beforeunload', (e) => {
|
||
if (isDirty.value) {
|
||
e.preventDefault()
|
||
e.returnValue = ''
|
||
}
|
||
})
|
||
|
||
// Global shortcuts
|
||
window.addEventListener('keydown', (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||
e.preventDefault()
|
||
showShortcuts.value = !showShortcuts.value
|
||
}
|
||
})
|
||
|
||
// Preferences
|
||
const prefs = ref({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, defaultMode: 'split', theme: 'dark' })
|
||
const timezones = Intl.supportedValuesOf ? Intl.supportedValuesOf('timeZone') : ['UTC', 'Europe/Stockholm', 'America/New_York', 'Asia/Tokyo']
|
||
|
||
// TOTP
|
||
const totpEnabled = ref(false)
|
||
const totpUri = ref('')
|
||
const totpSecret = ref('')
|
||
const totpCode = ref('')
|
||
|
||
// Password change
|
||
const pwCurrent = ref('')
|
||
const pwNew = ref('')
|
||
const pwMsg = ref('')
|
||
|
||
// Git remotes
|
||
const gitRemotes = ref([])
|
||
const newRemote = ref({ name: '', url: '' })
|
||
|
||
// Admin
|
||
const users = ref([])
|
||
const newUser = ref({ username: '', email: '', password: '', isAdmin: false })
|
||
const adminMsg = ref('')
|
||
const ldapSettings = ref({ ldap_url: '', ldap_bind_dn: '', ldap_bind_pass: '', ldap_base_dn: '', ldap_user_filter: '', ldap_group_filter: '' })
|
||
const ldapSkipTLS = ref(false)
|
||
const ldapMsg = ref('')
|
||
|
||
const rendered = computed(() => renderMarkdown(content.value))
|
||
|
||
watch(showHistory, (val) => { if (val) loadHistory() })
|
||
|
||
function toggleTheme() {
|
||
dark.value = !dark.value
|
||
document.documentElement.setAttribute('data-theme', dark.value ? 'dark' : 'light')
|
||
}
|
||
|
||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||
|
||
async function login() {
|
||
try {
|
||
const res = await api('/api/auth/login', { email: email.value, password: password.value })
|
||
token.value = res.token
|
||
setToken(res.token)
|
||
authenticated.value = true
|
||
isAdmin.value = res.isAdmin
|
||
loginError.value = ''
|
||
loadFiles()
|
||
loadShared()
|
||
loadRemotes()
|
||
syncPending()
|
||
if (isAdmin.value) { loadUsers(); loadLdapSettings() }
|
||
// Sync pending changes when coming back online
|
||
window.addEventListener('online', syncPending)
|
||
} catch (e) {
|
||
loginError.value = 'Invalid credentials'
|
||
}
|
||
}
|
||
|
||
function logout() {
|
||
api('/api/auth/logout', {}).catch(() => {})
|
||
token.value = ''
|
||
setToken('')
|
||
authenticated.value = false
|
||
window.removeEventListener('online', syncPending)
|
||
}
|
||
|
||
async function syncPending() {
|
||
const pending = await getPendingChanges()
|
||
for (const item of pending) {
|
||
try {
|
||
await api('/api/files/write', { path: item.path, content: item.content })
|
||
} catch { break }
|
||
}
|
||
if (pending.length) {
|
||
await clearAllPending()
|
||
loadFiles()
|
||
}
|
||
}
|
||
|
||
// ─── Files ───────────────────────────────────────────────────────────────────
|
||
|
||
async function loadFiles() {
|
||
loading.value = true
|
||
fileTree.value = await api('/api/files/list', {})
|
||
filteredFiles.value = fileTree.value
|
||
loading.value = false
|
||
}
|
||
|
||
function filterFiles() {
|
||
if (!searchQuery.value) {
|
||
filteredFiles.value = fileTree.value
|
||
return
|
||
}
|
||
const q = searchQuery.value.toLowerCase()
|
||
filteredFiles.value = filterTree(fileTree.value, q)
|
||
}
|
||
|
||
function filterTree(items, query) {
|
||
const result = []
|
||
for (const item of items) {
|
||
if (item.isDir) {
|
||
const children = filterTree(item.children || [], query)
|
||
if (children.length > 0) {
|
||
result.push({ ...item, children })
|
||
}
|
||
} else if (item.name.toLowerCase().includes(query)) {
|
||
result.push(item)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
async function openFile(path) {
|
||
if (isDirty.value && !confirm('Discard unsaved changes?')) return
|
||
try {
|
||
const res = await api('/api/files/read', { path })
|
||
currentFile.value = path
|
||
content.value = res.content
|
||
fileMeta.value = res.created ? `Created: ${formatDate(res.created)}` : ''
|
||
cacheFile(path, res.content)
|
||
} catch (e) {
|
||
// Offline fallback
|
||
const cached = await getCachedFile(path)
|
||
if (cached !== null) {
|
||
currentFile.value = path
|
||
content.value = cached
|
||
fileMeta.value = '(offline cache)'
|
||
} else {
|
||
toast('File unavailable offline', 'error')
|
||
return
|
||
}
|
||
}
|
||
isDirty.value = false
|
||
draftSaved.value = false
|
||
view.value = 'files'
|
||
showHistory.value = false
|
||
showShareDialog.value = false
|
||
aiResult.value = ''
|
||
|
||
// Check for unsaved draft
|
||
const draft = loadDraft()
|
||
if (draft && draft !== content.value) {
|
||
if (confirm('You have an unsaved draft for this file. Restore it?')) {
|
||
content.value = draft
|
||
isDirty.value = true
|
||
} else {
|
||
clearDraft()
|
||
}
|
||
}
|
||
loadHistory()
|
||
checkGitStatus()
|
||
}
|
||
|
||
// ─── Auto-draft ──────────────────────────────────────────────────────────────
|
||
|
||
const draftSaved = ref(false)
|
||
let draftTimer = null
|
||
|
||
function onEdit() {
|
||
isDirty.value = true
|
||
draftSaved.value = false
|
||
clearTimeout(draftTimer)
|
||
draftTimer = setTimeout(() => {
|
||
const key = currentFile.value || '_unsaved'
|
||
localStorage.setItem('mh_draft_' + key, content.value)
|
||
draftSaved.value = true
|
||
}, 1000)
|
||
}
|
||
|
||
function clearDraft() {
|
||
const key = currentFile.value || '_unsaved'
|
||
localStorage.removeItem('mh_draft_' + key)
|
||
draftSaved.value = false
|
||
}
|
||
|
||
function loadDraft() {
|
||
const key = currentFile.value || '_unsaved'
|
||
return localStorage.getItem('mh_draft_' + key)
|
||
}
|
||
|
||
async function saveFile() {
|
||
if (!currentFile.value) {
|
||
const name = prompt('Save as (e.g. notes.md):')
|
||
if (!name) return
|
||
let path = name
|
||
if (!path.endsWith('.md') && !path.endsWith('.txt')) {
|
||
path += '.md'
|
||
}
|
||
try {
|
||
await api('/api/files/create', { path, content: content.value })
|
||
currentFile.value = path
|
||
isDirty.value = false
|
||
await loadFiles()
|
||
} catch (e) {
|
||
await addPendingChange(path, content.value)
|
||
currentFile.value = path
|
||
isDirty.value = false
|
||
}
|
||
cacheFile(currentFile.value, content.value)
|
||
setTimeout(checkGitStatus, 1000)
|
||
return
|
||
}
|
||
try {
|
||
await api('/api/files/write', { path: currentFile.value, content: content.value })
|
||
} catch (e) {
|
||
await addPendingChange(currentFile.value, content.value)
|
||
}
|
||
cacheFile(currentFile.value, content.value)
|
||
isDirty.value = false
|
||
clearDraft()
|
||
setTimeout(checkGitStatus, 1000)
|
||
}
|
||
|
||
async function createFile() {
|
||
const name = prompt('File name (e.g. notes.md):')
|
||
if (!name) return
|
||
let path = name
|
||
if (!path.endsWith('.md') && !path.endsWith('.txt')) {
|
||
path += '.md'
|
||
}
|
||
await api('/api/files/create', { path, content: '' })
|
||
await loadFiles()
|
||
openFile(path)
|
||
}
|
||
|
||
async function createFolder() {
|
||
const name = prompt('Folder name (e.g. projects):')
|
||
if (!name) return
|
||
await api('/api/files/create-folder', { path: name })
|
||
await loadFiles()
|
||
}
|
||
|
||
async function deleteItem(item) {
|
||
const msg = item.isDir
|
||
? `Delete folder "${item.name}" and all files in it?`
|
||
: `Delete "${item.name}"?`
|
||
if (!confirm(msg)) return
|
||
await api('/api/files/delete', { path: item.path })
|
||
if (currentFile.value === item.path) {
|
||
currentFile.value = ''
|
||
content.value = ''
|
||
isDirty.value = false
|
||
}
|
||
await loadFiles()
|
||
}
|
||
|
||
async function moveFile({ from, to }) {
|
||
const filename = from.split('/').pop()
|
||
const newPath = to + '/' + filename
|
||
try {
|
||
await api('/api/files/move', { from, to: newPath })
|
||
if (currentFile.value === from) {
|
||
currentFile.value = newPath
|
||
}
|
||
await loadFiles()
|
||
} catch (e) {
|
||
toast('Move failed', 'error')
|
||
}
|
||
}
|
||
|
||
async function renameFile(item) {
|
||
const newName = prompt('New name:', item.name)
|
||
if (!newName || newName === item.name) return
|
||
try {
|
||
const res = await api('/api/files/rename', { path: item.path, new_name: newName })
|
||
if (currentFile.value === item.path) {
|
||
currentFile.value = res.new_path
|
||
}
|
||
await loadFiles()
|
||
toast('Renamed', 'success')
|
||
} catch (e) {
|
||
toast('Rename failed', 'error')
|
||
}
|
||
}
|
||
|
||
function sortFiles(by) {
|
||
sortBy.value = by
|
||
const sorter = (a, b) => {
|
||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
||
if (by === 'name') return a.name.localeCompare(b.name)
|
||
return 0 // date sort would need mtime from server
|
||
}
|
||
filteredFiles.value = [...filteredFiles.value].sort(sorter)
|
||
}
|
||
|
||
async function handleImageDrop(e) {
|
||
const files = e.dataTransfer?.files
|
||
if (!files || !files.length) return
|
||
for (const file of files) {
|
||
if (!file.type.startsWith('image/')) continue
|
||
const form = new FormData()
|
||
form.append('image', file)
|
||
try {
|
||
const res = await fetch('/api/files/upload-image', {
|
||
method: 'POST',
|
||
headers: { 'Authorization': 'Bearer ' + token.value },
|
||
body: form,
|
||
})
|
||
const data = await res.json()
|
||
if (data.url) {
|
||
const md = `\n`
|
||
const ta = editor.value
|
||
const pos = ta ? ta.selectionStart : content.value.length
|
||
content.value = content.value.substring(0, pos) + md + content.value.substring(pos)
|
||
isDirty.value = true
|
||
toast('Image uploaded', 'success')
|
||
}
|
||
} catch {
|
||
toast('Image upload failed', 'error')
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Shared ──────────────────────────────────────────────────────────────────
|
||
|
||
async function loadShared() {
|
||
try {
|
||
const data = await api('/api/files/shared', {})
|
||
// Convert to FileTree-compatible format
|
||
sharedFiles.value = (data || []).filter(f => f.type !== 'outgoing').map(f => ({
|
||
name: `${f.path} (${f.owner}, ${f.level})`,
|
||
path: f.path,
|
||
isDir: false,
|
||
owner_id: f.owner_id,
|
||
}))
|
||
} catch (e) {
|
||
sharedFiles.value = []
|
||
}
|
||
}
|
||
|
||
async function openSharedFile(path) {
|
||
// Find the shared file entry to get owner_id
|
||
const entry = sharedFiles.value.find(f => f.path === path)
|
||
if (!entry || !entry.owner_id) {
|
||
openFile(path)
|
||
return
|
||
}
|
||
try {
|
||
const res = await api('/api/files/read', { path, owner_id: entry.owner_id })
|
||
currentFile.value = path
|
||
content.value = res.content
|
||
fileMeta.value = `Shared by ${entry.name.split('(')[1]?.split(')')[0] || 'unknown'}`
|
||
isDirty.value = false
|
||
view.value = 'shared'
|
||
} catch (e) {
|
||
toast('Cannot open shared file', 'error')
|
||
}
|
||
}
|
||
|
||
async function loadTrash() {
|
||
try {
|
||
trashItems.value = await api('/api/files/trash', {})
|
||
} catch (e) {
|
||
trashItems.value = []
|
||
}
|
||
}
|
||
|
||
async function restoreTrash(name) {
|
||
await api('/api/files/trash/restore', { name })
|
||
await loadTrash()
|
||
await loadFiles()
|
||
}
|
||
|
||
async function emptyTrash() {
|
||
if (!confirm('Permanently delete all items in trash?')) return
|
||
await api('/api/files/trash/empty', {})
|
||
trashItems.value = []
|
||
}
|
||
|
||
function dropToTrash(e) {
|
||
const path = e.dataTransfer.getData('text/plain')
|
||
if (!path) return
|
||
deleteItem({ path, name: path.split('/').pop(), isDir: false })
|
||
}
|
||
|
||
// ─── Preferences ─────────────────────────────────────────────────────────────
|
||
|
||
function savePrefs() {
|
||
localStorage.setItem('mh_prefs', JSON.stringify(prefs.value))
|
||
}
|
||
|
||
function applyThemeFromPrefs() {
|
||
dark.value = prefs.value.theme === 'dark'
|
||
document.documentElement.setAttribute('data-theme', prefs.value.theme)
|
||
savePrefs()
|
||
}
|
||
|
||
async function changePassword() {
|
||
try {
|
||
await api('/api/auth/change-password', { current_password: pwCurrent.value, new_password: pwNew.value })
|
||
pwMsg.value = 'Password changed successfully'
|
||
pwCurrent.value = ''
|
||
pwNew.value = ''
|
||
} catch (e) {
|
||
pwMsg.value = 'Failed — check current password'
|
||
}
|
||
}
|
||
|
||
// ─── TOTP ────────────────────────────────────────────────────────────────────
|
||
|
||
async function setupTOTP() {
|
||
try {
|
||
const res = await api('/api/auth/totp/setup', {})
|
||
totpUri.value = res.uri
|
||
totpSecret.value = res.secret
|
||
} catch (e) {
|
||
toast('Failed to setup 2FA', 'error')
|
||
}
|
||
}
|
||
|
||
async function verifyTOTP() {
|
||
try {
|
||
await api('/api/auth/totp/verify', { code: totpCode.value })
|
||
totpEnabled.value = true
|
||
totpUri.value = ''
|
||
totpSecret.value = ''
|
||
totpCode.value = ''
|
||
toast('2FA enabled!', 'success')
|
||
} catch (e) {
|
||
toast('Invalid code', 'error')
|
||
}
|
||
}
|
||
|
||
async function disableTOTP() {
|
||
try {
|
||
await api('/api/auth/totp/disable', { code: totpCode.value })
|
||
totpEnabled.value = false
|
||
totpCode.value = ''
|
||
toast('2FA disabled', 'success')
|
||
} catch (e) {
|
||
toast('Invalid code', 'error')
|
||
}
|
||
}
|
||
|
||
// ─── Git Remotes ─────────────────────────────────────────────────────────────
|
||
|
||
async function loadRemotes() {
|
||
try {
|
||
gitRemotes.value = await api('/api/git/remote/list', {})
|
||
} catch { gitRemotes.value = [] }
|
||
}
|
||
|
||
async function addRemote() {
|
||
if (!newRemote.value.name || !newRemote.value.url) return
|
||
await api('/api/git/remote/add', newRemote.value)
|
||
newRemote.value = { name: '', url: '' }
|
||
loadRemotes()
|
||
}
|
||
|
||
async function gitPush() {
|
||
try {
|
||
await api('/api/git/push', { remote: gitRemotes.value[0]?.name || 'origin' })
|
||
toast('Pushed successfully', 'success')
|
||
checkGitStatus()
|
||
} catch (e) {
|
||
toast('Push failed', 'error')
|
||
}
|
||
}
|
||
|
||
async function gitPull() {
|
||
try {
|
||
await api('/api/git/pull', { remote: gitRemotes.value[0]?.name || 'origin' })
|
||
toast('Pulled successfully', 'success')
|
||
loadFiles()
|
||
checkGitStatus()
|
||
} catch (e) {
|
||
toast('Pull failed', 'error')
|
||
}
|
||
}
|
||
|
||
// ─── Admin ───────────────────────────────────────────────────────────────────
|
||
|
||
async function loadUsers() {
|
||
try {
|
||
users.value = await api('/api/users/list', {})
|
||
} catch (e) { /* not admin */ }
|
||
}
|
||
|
||
async function adminCreateUser() {
|
||
try {
|
||
await api('/api/users/create', newUser.value)
|
||
adminMsg.value = `User "${newUser.value.username}" created`
|
||
newUser.value = { username: '', email: '', password: '', isAdmin: false }
|
||
loadUsers()
|
||
} catch (e) {
|
||
adminMsg.value = 'Failed to create user'
|
||
}
|
||
}
|
||
|
||
async function loadLdapSettings() {
|
||
try {
|
||
ldapSettings.value = await api('/api/admin/settings/get', {})
|
||
ldapSkipTLS.value = ldapSettings.value.ldap_skip_tls === 'true'
|
||
} catch {}
|
||
}
|
||
|
||
async function saveLdapSettings() {
|
||
const data = { ...ldapSettings.value, ldap_skip_tls: ldapSkipTLS.value ? 'true' : 'false' }
|
||
try {
|
||
await api('/api/admin/settings/save', data)
|
||
ldapMsg.value = 'LDAP settings saved'
|
||
toast('LDAP settings saved', 'success')
|
||
} catch {
|
||
ldapMsg.value = 'Failed to save'
|
||
}
|
||
}
|
||
|
||
function formatDate(d) {
|
||
if (!d) return ''
|
||
try {
|
||
return new Date(d).toLocaleString(undefined, { timeZone: prefs.value.timezone })
|
||
} catch { return d }
|
||
}
|
||
|
||
// ─── Git ─────────────────────────────────────────────────────────────────────
|
||
|
||
async function checkGitStatus() {
|
||
try {
|
||
const res = await api('/api/git/status', {})
|
||
gitDirty.value = res.dirty || 0
|
||
} catch { gitDirty.value = 0 }
|
||
}
|
||
|
||
async function gitSync() {
|
||
await api('/api/git/commit', { message: 'Manual sync' })
|
||
await checkGitStatus()
|
||
}
|
||
|
||
// ─── History ─────────────────────────────────────────────────────────────────
|
||
|
||
async function loadHistory() {
|
||
if (!currentFile.value) return
|
||
try {
|
||
history.value = await api('/api/git/log', { path: currentFile.value, limit: 20 })
|
||
} catch { history.value = [] }
|
||
}
|
||
|
||
async function restoreVersion(hash) {
|
||
if (!confirm(`Restore ${currentFile.value} to commit ${hash.slice(0, 7)}?`)) return
|
||
await api('/api/git/restore', { path: currentFile.value, hash })
|
||
const res = await api('/api/files/read', { path: currentFile.value })
|
||
content.value = res.content
|
||
isDirty.value = false
|
||
showHistory.value = false
|
||
}
|
||
|
||
// ─── Sharing ─────────────────────────────────────────────────────────────────
|
||
|
||
async function shareFile() {
|
||
if (!shareUsername.value) return
|
||
try {
|
||
await api('/api/share', { path: currentFile.value, username: shareUsername.value, level: shareLevel.value })
|
||
shareMsg.value = `Shared with ${shareUsername.value} (${shareLevel.value})`
|
||
shareUsername.value = ''
|
||
} catch (e) {
|
||
shareMsg.value = 'Failed to share'
|
||
}
|
||
}
|
||
|
||
// ─── AI ──────────────────────────────────────────────────────────────────────
|
||
|
||
async function aiVerify() {
|
||
if (!currentFile.value) return
|
||
aiResult.value = 'Verifying...'
|
||
try {
|
||
const res = await api('/api/ai/verify', { path: currentFile.value })
|
||
aiResult.value = res.feedback || 'No response'
|
||
} catch (e) {
|
||
aiResult.value = 'AI verification failed. Check MH_AI_ENDPOINT configuration.'
|
||
}
|
||
}
|
||
|
||
// ─── Collab ──────────────────────────────────────────────────────────────────
|
||
|
||
function toggleCollab() {
|
||
if (collabActive.value) {
|
||
disconnectCollab()
|
||
collabActive.value = false
|
||
} else {
|
||
if (!currentFile.value) return
|
||
const fileId = currentFile.value.replace(/[^a-zA-Z0-9]/g, '-')
|
||
connectCollab(fileId, (newContent) => {
|
||
content.value = newContent
|
||
})
|
||
setCollabContent(content.value)
|
||
collabActive.value = true
|
||
}
|
||
}
|
||
|
||
// ─── Formatting ──────────────────────────────────────────────────────────────
|
||
|
||
function insertFormat(before, after) {
|
||
const ta = editor.value
|
||
if (!ta) return
|
||
const start = ta.selectionStart
|
||
const end = ta.selectionEnd
|
||
const selected = content.value.substring(start, end)
|
||
const replacement = before + (selected || 'text') + after
|
||
content.value = content.value.substring(0, start) + replacement + content.value.substring(end)
|
||
isDirty.value = true
|
||
// Set cursor position after insert
|
||
const cursorPos = start + before.length + (selected ? selected.length : 4)
|
||
requestAnimationFrame(() => {
|
||
ta.focus()
|
||
ta.setSelectionRange(cursorPos, cursorPos)
|
||
})
|
||
}
|
||
|
||
function insertPrefix(prefix) {
|
||
const ta = editor.value
|
||
if (!ta) return
|
||
const start = ta.selectionStart
|
||
// Find start of current line
|
||
const lineStart = content.value.lastIndexOf('\n', start - 1) + 1
|
||
content.value = content.value.substring(0, lineStart) + prefix + content.value.substring(lineStart)
|
||
isDirty.value = true
|
||
const cursorPos = start + prefix.length
|
||
requestAnimationFrame(() => {
|
||
ta.focus()
|
||
ta.setSelectionRange(cursorPos, cursorPos)
|
||
})
|
||
}
|
||
|
||
function insertCodeBlock() {
|
||
const ta = editor.value
|
||
if (!ta) return
|
||
const start = ta.selectionStart
|
||
const end = ta.selectionEnd
|
||
const selected = content.value.substring(start, end)
|
||
const block = '\n```\n' + (selected || '') + '\n```\n'
|
||
content.value = content.value.substring(0, start) + block + content.value.substring(end)
|
||
isDirty.value = true
|
||
const cursorPos = start + 5 + (selected ? selected.length : 0)
|
||
requestAnimationFrame(() => {
|
||
ta.focus()
|
||
ta.setSelectionRange(cursorPos, cursorPos)
|
||
})
|
||
}
|
||
|
||
// ─── Keyboard Shortcuts ──────────────────────────────────────────────────────
|
||
|
||
function handleKeydown(e) {
|
||
const ctrl = e.ctrlKey || e.metaKey
|
||
if (ctrl && e.key === 's') {
|
||
e.preventDefault()
|
||
saveFile()
|
||
} else if (ctrl && e.key === 'b') {
|
||
e.preventDefault()
|
||
insertFormat('**', '**')
|
||
} else if (ctrl && e.key === 'i') {
|
||
e.preventDefault()
|
||
insertFormat('*', '*')
|
||
} else if (ctrl && e.key === 'k') {
|
||
e.preventDefault()
|
||
insertFormat('[', '](url)')
|
||
} else if (ctrl && e.key === '`') {
|
||
e.preventDefault()
|
||
insertFormat('`', '`')
|
||
} else if (e.key === 'Tab') {
|
||
e.preventDefault()
|
||
const ta = editor.value
|
||
const start = ta.selectionStart
|
||
content.value = content.value.substring(0, start) + ' ' + content.value.substring(ta.selectionEnd)
|
||
isDirty.value = true
|
||
requestAnimationFrame(() => {
|
||
ta.setSelectionRange(start + 2, start + 2)
|
||
})
|
||
}
|
||
}
|
||
|
||
// ─── Export ──────────────────────────────────────────────────────────────────
|
||
|
||
function exportMD() {
|
||
download(currentFile.value, content.value, 'text/markdown')
|
||
}
|
||
|
||
function exportHTML() {
|
||
const html = `<!DOCTYPE html>
|
||
<html><head><meta charset="utf-8"><title>${currentFile.value}</title>
|
||
<style>body{font-family:-apple-system,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7}
|
||
code{background:#f0f0f0;padding:2px 6px;border-radius:3px}pre{background:#f0f0f0;padding:16px;border-radius:6px;overflow-x:auto}
|
||
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px 12px}th{background:#f5f5f5}
|
||
blockquote{border-left:4px solid #0969da;padding:0 16px;color:#656d76}</style>
|
||
</head><body>${rendered.value}</body></html>`
|
||
download(currentFile.value.replace(/\.md$/, '.html'), html, 'text/html')
|
||
}
|
||
|
||
function exportPDF() {
|
||
const win = window.open('', '_blank')
|
||
win.document.write(`<!DOCTYPE html>
|
||
<html><head><meta charset="utf-8"><title>${currentFile.value}</title>
|
||
<style>body{font-family:-apple-system,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7}
|
||
code{background:#f0f0f0;padding:2px 6px;border-radius:3px}pre{background:#f0f0f0;padding:16px;border-radius:6px;overflow-x:auto}
|
||
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px 12px}th{background:#f5f5f5}
|
||
blockquote{border-left:4px solid #0969da;padding:0 16px;color:#656d76}</style>
|
||
</head><body>${rendered.value}</body></html>`)
|
||
win.document.close()
|
||
setTimeout(() => { win.print() }, 300)
|
||
}
|
||
|
||
function download(filename, content, mime) {
|
||
const blob = new Blob([content], { type: mime })
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob)
|
||
a.download = filename
|
||
a.click()
|
||
URL.revokeObjectURL(a.href)
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg-primary); color: var(--text); }
|
||
|
||
:root, [data-theme="dark"] {
|
||
--bg-primary: #1e1e2e;
|
||
--bg-secondary: #181825;
|
||
--bg-tertiary: #11111b;
|
||
--bg-hover: #313244;
|
||
--bg-selected: #45475a;
|
||
--border: #313244;
|
||
--text: #cdd6f4;
|
||
--text-muted: #a6adc8;
|
||
--accent: #89b4fa;
|
||
--success: #a6e3a1;
|
||
--danger: #f38ba8;
|
||
--code-bg: #313244;
|
||
}
|
||
|
||
[data-theme="light"] {
|
||
--bg-primary: #ffffff;
|
||
--bg-secondary: #f5f5f5;
|
||
--bg-tertiary: #fafafa;
|
||
--bg-hover: #e8e8e8;
|
||
--bg-selected: #d0d0d0;
|
||
--border: #e0e0e0;
|
||
--text: #1e1e2e;
|
||
--text-muted: #656d76;
|
||
--accent: #0969da;
|
||
--success: #1a7f37;
|
||
--danger: #cf222e;
|
||
--code-bg: #f0f0f0;
|
||
}
|
||
|
||
.app {
|
||
display: flex;
|
||
height: 100vh;
|
||
background: var(--bg-primary);
|
||
color: var(--text);
|
||
}
|
||
|
||
/* ─── Sidebar ─────────────────────────────────────────────────────────────── */
|
||
|
||
.sidebar {
|
||
width: 260px;
|
||
background: var(--bg-primary);
|
||
color: var(--text);
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-right: 1px solid var(--border);
|
||
}
|
||
|
||
.sidebar-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.sidebar-header h2 { font-size: 16px; }
|
||
.sidebar-header button {
|
||
background: var(--accent);
|
||
color: var(--bg-primary);
|
||
border: none;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
}
|
||
.sidebar-actions { display: flex; gap: 4px; }
|
||
|
||
.search-box {
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.search-box input {
|
||
width: 100%;
|
||
padding: 6px 10px;
|
||
background: var(--bg-hover);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
outline: none;
|
||
}
|
||
.search-box input:focus { border-color: var(--accent); }
|
||
|
||
/* ─── Toolbar ─────────────────────────────────────────────────────────────── */
|
||
|
||
.editor-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
background: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.mode-switcher button {
|
||
background: var(--bg-hover);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
padding: 4px 12px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
}
|
||
.mode-switcher button.active {
|
||
background: var(--accent);
|
||
color: var(--bg-primary);
|
||
}
|
||
.mode-switcher button:first-child { border-radius: 4px 0 0 4px; }
|
||
.mode-switcher button:last-child { border-radius: 0 4px 4px 0; }
|
||
|
||
.format-toolbar {
|
||
display: flex;
|
||
gap: 2px;
|
||
padding: 0 8px;
|
||
border-left: 1px solid var(--border);
|
||
border-right: 1px solid var(--border);
|
||
}
|
||
.format-toolbar button {
|
||
background: transparent;
|
||
color: var(--text);
|
||
border: none;
|
||
padding: 4px 8px;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
min-width: 28px;
|
||
}
|
||
.format-toolbar button:hover { background: var(--bg-hover); }
|
||
|
||
.toolbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.file-name { color: var(--text-muted); font-size: 13px; }
|
||
.draft-indicator { color: var(--success); font-size: 11px; opacity: 0.8; }
|
||
|
||
.export-actions { display: flex; gap: 4px; }
|
||
.export-actions button {
|
||
background: var(--bg-hover);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
padding: 4px 10px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
}
|
||
.export-actions button:hover { background: var(--bg-selected); }
|
||
|
||
.save-btn {
|
||
background: var(--success);
|
||
color: var(--bg-primary);
|
||
border: none;
|
||
padding: 6px 16px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
.save-btn.dirty { background: var(--danger); }
|
||
|
||
/* ─── Editor ──────────────────────────────────────────────────────────────── */
|
||
|
||
.editor-container {
|
||
flex: 1;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
.editor-container.raw .raw-pane { flex: 1; }
|
||
.editor-container.wysiwyg .wysiwyg-pane { flex: 1; }
|
||
.editor-container.split .raw-pane,
|
||
.editor-container.split .preview-pane { flex: 1; }
|
||
.editor-container.split .raw-pane { border-right: 1px solid var(--border); }
|
||
|
||
.wysiwyg-pane {
|
||
overflow-y: auto;
|
||
background: var(--bg-primary);
|
||
color: var(--text);
|
||
}
|
||
|
||
.raw-pane textarea {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: var(--bg-primary);
|
||
color: var(--text);
|
||
border: none;
|
||
padding: 20px;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
resize: none;
|
||
outline: none;
|
||
tab-size: 2;
|
||
}
|
||
|
||
/* ─── Preview ─────────────────────────────────────────────────────────────── */
|
||
|
||
.preview-pane {
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
background: var(--bg-tertiary);
|
||
color: var(--text);
|
||
line-height: 1.7;
|
||
font-size: 15px;
|
||
}
|
||
.preview-pane h1 { font-size: 2em; margin: 0.67em 0; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); color: var(--text); }
|
||
.preview-pane h2 { font-size: 1.5em; margin: 0.83em 0; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); color: var(--text); }
|
||
.preview-pane h3 { font-size: 1.25em; margin: 1em 0; color: var(--text); }
|
||
.preview-pane h4, .preview-pane h5, .preview-pane h6 { margin: 1em 0; color: var(--text-muted); }
|
||
.preview-pane p { margin: 0 0 16px; }
|
||
.preview-pane code { background: var(--code-bg); padding: 2px 6px; border-radius: 3px; font-size: 85%; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
|
||
.preview-pane pre { background: var(--code-bg); padding: 16px; border-radius: 6px; overflow-x: auto; margin: 0 0 16px; }
|
||
.preview-pane pre code { background: none; padding: 0; font-size: 85%; }
|
||
.preview-pane ul, .preview-pane ol { padding-left: 2em; margin: 0 0 16px; }
|
||
.preview-pane li { margin: 4px 0; }
|
||
.preview-pane blockquote { border-left: 4px solid var(--accent); padding: 0 16px; color: var(--text-muted); margin: 0 0 16px; }
|
||
.preview-pane table { border-collapse: collapse; width: 100%; margin: 0 0 16px; }
|
||
.preview-pane th, .preview-pane td { border: 1px solid var(--border); padding: 8px 12px; text-align: left; }
|
||
.preview-pane th { background: var(--code-bg); }
|
||
.preview-pane hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
|
||
.preview-pane a { color: var(--accent); text-decoration: none; }
|
||
.preview-pane a:hover { text-decoration: underline; }
|
||
.preview-pane img { max-width: 100%; border-radius: 4px; }
|
||
.preview-pane input[type="checkbox"] { margin-right: 6px; }
|
||
|
||
/* ─── Login ───────────────────────────────────────────────────────────────── */
|
||
|
||
.login {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100vh;
|
||
background: var(--bg-primary);
|
||
}
|
||
.login form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
width: 300px;
|
||
}
|
||
.login h1 { color: var(--text); text-align: center; margin-bottom: 12px; }
|
||
.login input {
|
||
padding: 10px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
background: var(--bg-hover);
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
}
|
||
.login button {
|
||
padding: 10px;
|
||
background: var(--accent);
|
||
color: var(--bg-primary);
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
.login .error { color: var(--danger); text-align: center; font-size: 13px; }
|
||
|
||
/* ─── Sidebar Nav ─────────────────────────────────────────────────────────── */
|
||
|
||
.sidebar-nav {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 2px;
|
||
padding: 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.sidebar-nav button {
|
||
flex: 1;
|
||
background: transparent;
|
||
color: var(--text-muted);
|
||
border: none;
|
||
padding: 6px 4px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
white-space: nowrap;
|
||
}
|
||
.sidebar-nav button.active { background: var(--bg-hover); color: var(--text); }
|
||
.sidebar-nav button:hover { background: var(--bg-hover); }
|
||
|
||
/* ─── Panels ──────────────────────────────────────────────────────────────── */
|
||
|
||
.panel {
|
||
flex: 1;
|
||
padding: 32px;
|
||
overflow-y: auto;
|
||
background: var(--bg-tertiary);
|
||
color: var(--text);
|
||
}
|
||
.panel h2 { margin-bottom: 24px; }
|
||
.panel h3 { margin: 16px 0 8px; }
|
||
.panel-section { margin-bottom: 24px; }
|
||
.panel-section label { display: block; margin-bottom: 6px; color: var(--text-muted); font-size: 13px; }
|
||
.panel-section select, .panel-section input[type="text"] {
|
||
padding: 8px 12px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
min-width: 200px;
|
||
}
|
||
|
||
.admin-form {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
.admin-form input {
|
||
padding: 8px 12px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
}
|
||
.admin-form button {
|
||
padding: 8px 16px;
|
||
background: var(--accent);
|
||
color: var(--bg-primary);
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
.admin-msg { margin-top: 8px; color: var(--success); font-size: 13px; }
|
||
|
||
.user-table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||
.user-table th, .user-table td { padding: 8px 12px; border: 1px solid var(--border); text-align: left; font-size: 13px; }
|
||
.user-table th { background: var(--bg-hover); }
|
||
|
||
.file-meta { color: var(--text-muted); font-size: 11px; }
|
||
|
||
.toolbar-btn {
|
||
background: transparent;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
padding: 4px 10px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
}
|
||
.toolbar-btn:hover { background: var(--bg-hover); }
|
||
|
||
.git-btn {
|
||
background: transparent;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
padding: 4px 10px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
}
|
||
.git-btn.dirty { border-color: var(--danger); }
|
||
|
||
.history-panel, .share-dialog, .ai-panel {
|
||
position: absolute;
|
||
right: 0;
|
||
top: 50px;
|
||
width: 360px;
|
||
max-width: calc(100vw - 280px);
|
||
max-height: calc(100vh - 60px);
|
||
overflow-y: auto;
|
||
background: var(--bg-primary);
|
||
border-left: 1px solid var(--border);
|
||
border-bottom: 1px solid var(--border);
|
||
z-index: 20;
|
||
box-shadow: -2px 2px 8px rgba(0,0,0,0.3);
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.panel-header h3 { font-size: 14px; }
|
||
.panel-header button { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 18px; }
|
||
|
||
.history-list { padding: 8px; }
|
||
.history-item {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 8px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
align-items: center;
|
||
}
|
||
.history-item:hover { background: var(--bg-hover); }
|
||
.history-hash { color: var(--accent); font-family: monospace; }
|
||
.history-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.history-date { color: var(--text-muted); font-size: 11px; white-space: nowrap; }
|
||
|
||
.share-form {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 12px 16px;
|
||
align-items: center;
|
||
}
|
||
.share-form input, .share-form select {
|
||
padding: 6px 10px;
|
||
background: var(--bg-hover);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
}
|
||
.share-form button {
|
||
padding: 6px 12px;
|
||
background: var(--accent);
|
||
color: var(--bg-primary);
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
.share-msg { padding: 0 16px 12px; font-size: 12px; color: var(--success); }
|
||
|
||
.ai-content { padding: 16px; font-size: 14px; line-height: 1.6; }
|
||
.ai-content h1, .ai-content h2, .ai-content h3 { margin: 12px 0 6px; }
|
||
.ai-content p { margin: 0 0 12px; }
|
||
.ai-content code { background: var(--code-bg); padding: 2px 6px; border-radius: 3px; }
|
||
.ai-content ul, .ai-content ol { padding-left: 2em; margin: 0 0 12px; }
|
||
|
||
.trash-view { padding: 8px; flex: 1; overflow-y: auto; }
|
||
|
||
.sort-bar {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 4px 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.sort-bar button {
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-muted);
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
padding: 2px 8px;
|
||
border-radius: 3px;
|
||
}
|
||
.sort-bar button.active { background: var(--bg-hover); color: var(--text); }
|
||
|
||
.loading-bar {
|
||
height: 2px;
|
||
background: var(--accent);
|
||
animation: loadPulse 1s infinite;
|
||
}
|
||
@keyframes loadPulse { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
|
||
.trash-header { display: flex; justify-content: space-between; align-items: center; padding: 8px; font-size: 12px; color: var(--text-muted); }
|
||
.empty-trash-btn { background: var(--danger); color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||
.trash-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; border-radius: 4px; font-size: 13px; }
|
||
.trash-item:hover { background: var(--bg-hover); }
|
||
.trash-item button { background: none; border: none; cursor: pointer; font-size: 14px; }
|
||
.trash-empty { color: var(--text-muted); font-size: 13px; text-align: center; padding: 20px; }
|
||
|
||
.action-btn { background: var(--accent); color: var(--bg-primary); border: none; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||
.totp-setup { margin-top: 12px; }
|
||
.totp-secret { display: block; margin: 8px 0; padding: 8px; background: var(--code-bg); border-radius: 4px; font-size: 14px; letter-spacing: 2px; }
|
||
.totp-verify { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
|
||
.totp-verify input { padding: 6px 10px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 14px; width: 140px; }
|
||
.totp-verify button { padding: 6px 12px; background: var(--accent); color: var(--bg-primary); border: none; border-radius: 4px; cursor: pointer; }
|
||
|
||
.remote-item { display: flex; gap: 12px; align-items: center; padding: 6px 0; font-size: 13px; }
|
||
.remote-item code { color: var(--text-muted); font-size: 12px; }
|
||
.remote-form { display: flex; gap: 8px; margin-top: 12px; align-items: center; }
|
||
.remote-form input { padding: 6px 10px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 13px; }
|
||
.remote-form button { padding: 6px 12px; background: var(--accent); color: var(--bg-primary); border: none; border-radius: 4px; cursor: pointer; }
|
||
.remote-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||
|
||
/* ─── Responsive ──────────────────────────────────────────────────────────── */
|
||
|
||
.hamburger {
|
||
display: none;
|
||
position: fixed;
|
||
top: 8px;
|
||
left: 8px;
|
||
z-index: 20;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
font-size: 20px;
|
||
padding: 6px 10px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.hamburger { display: block; }
|
||
.sidebar { position: fixed; left: -260px; z-index: 10; transition: left 0.2s; }
|
||
.sidebar.open { left: 0; }
|
||
.format-toolbar { display: none; }
|
||
}
|
||
|
||
/* ─── Overlay ─────────────────────────────────────────────────────────────── */
|
||
|
||
.overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 9000;
|
||
}
|
||
.overlay-content {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 24px;
|
||
min-width: 300px;
|
||
}
|
||
.overlay-content table { width: 100%; margin-top: 12px; }
|
||
.overlay-content td { padding: 4px 8px; font-size: 13px; }
|
||
.overlay-content kbd {
|
||
background: var(--bg-hover);
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
padding: 2px 6px;
|
||
font-size: 12px;
|
||
font-family: monospace;
|
||
}
|
||
|
||
/* ─── Toasts ──────────────────────────────────────────────────────────────── */
|
||
|
||
.toast-container {
|
||
position: fixed;
|
||
top: 16px;
|
||
right: 16px;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.toast {
|
||
padding: 10px 16px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
color: #fff;
|
||
background: var(--bg-hover);
|
||
border: 1px solid var(--border);
|
||
animation: slideIn 0.2s ease;
|
||
}
|
||
.toast.success { background: #1a7f37; border-color: #2ea043; }
|
||
.toast.error { background: #cf222e; border-color: #f38ba8; }
|
||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||
</style>
|