Files
markdown-hub/frontend/src/App.vue
T
anders b020d2e193 Stream AI responses (SSE) - text appears as it generates
- Backend streams tokens via Server-Sent Events
- Frontend reads stream with fetch + ReadableStream
- Edit mode: document updates live as tokens arrive
- Chat mode: response text appears progressively
- No more waiting for full generation to complete
2026-05-27 11:03:30 +02:00

1841 lines
62 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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+`)">&lt;/&gt;</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>
<!-- AI Chat Panel -->
<div class="ai-chat-panel" v-if="currentFile">
<div class="ai-chat-modes">
<button :class="{active: aiChatMode === 'edit'}" @click="aiChatMode = 'edit'">Edit</button>
<button :class="{active: aiChatMode === 'chat'}" @click="aiChatMode = 'chat'">Chat</button>
<select v-model="aiVerifyType" @change="runVerify" class="ai-verify-select">
<option value="" disabled>Verify ▾</option>
<option value="spec">Spec Review</option>
<option value="grammar">Grammar & Spelling</option>
<option value="summary">Summary</option>
</select>
<span v-if="aiChatLoading" class="ai-loading">⏳</span>
</div>
<div class="ai-chat-output" v-if="aiChatResponse" v-html="renderMarkdown(aiChatResponse)"></div>
<div class="ai-chat-input">
<textarea
v-model="aiChatInput"
@keydown="aiChatKeydown"
placeholder="Ask AI to edit or chat... (Enter to send, Shift+Enter for newline)"
rows="2"
></textarea>
<button @click="sendAiChat" class="ai-send-btn">Send</button>
</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 = `![${data.filename}](${data.url})\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 ──────────────────────────────────────────────────────────────────────
const aiChatMode = ref('edit')
const aiChatInput = ref('')
const aiChatResponse = ref('')
const aiChatLoading = ref(false)
const aiVerifyType = ref('')
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.'
}
}
async function runVerify() {
if (!aiVerifyType.value || !currentFile.value) return
aiChatLoading.value = true
aiChatResponse.value = ''
try {
const res = await api('/api/ai/generate', { path: currentFile.value, action: aiVerifyType.value })
aiChatResponse.value = res.result || res.feedback || 'No response'
} catch (e) {
aiChatResponse.value = 'AI request failed.'
}
aiChatLoading.value = false
aiVerifyType.value = ''
}
function aiChatKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendAiChat()
}
}
async function sendAiChat() {
const msg = aiChatInput.value.trim()
if (!msg) return
aiChatLoading.value = true
aiChatResponse.value = ''
aiChatInput.value = ''
const action = aiChatMode.value === 'edit' ? 'edit' : 'chat'
try {
const res = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token.value },
body: JSON.stringify({ path: currentFile.value, content: content.value, message: msg, mode: action }),
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
let fullText = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6)
if (data === '[DONE]') break
try {
const token_text = JSON.parse(data)
fullText += token_text
if (action === 'edit') {
content.value = fullText
} else {
aiChatResponse.value = fullText
}
} catch {}
}
}
if (action === 'edit') {
isDirty.value = true
aiChatResponse.value = 'Document updated.'
}
} catch (e) {
aiChatResponse.value = 'AI request failed.'
}
aiChatLoading.value = false
}
// ─── 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); }
/* ─── AI Chat Panel ───────────────────────────────────────────────────────── */
.ai-chat-panel {
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--bg-secondary);
margin-top: auto;
}
.ai-chat-modes {
display: flex;
gap: 4px;
padding: 4px 8px;
align-items: center;
border-bottom: 1px solid var(--border);
}
.ai-chat-modes button {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 2px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.ai-chat-modes button.active {
background: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
}
.ai-verify-select {
background: var(--bg-primary);
border: 1px solid var(--border);
color: var(--text);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.ai-loading { font-size: 12px; }
.ai-chat-output {
flex: 1;
overflow-y: auto;
padding: 6px 10px;
font-size: 12px;
line-height: 1.5;
color: var(--text-muted);
}
.ai-chat-output p { margin: 0 0 6px; }
.ai-chat-input {
display: flex;
gap: 4px;
padding: 4px 8px;
border-top: 1px solid var(--border);
}
.ai-chat-input textarea {
flex: 1;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
resize: none;
outline: none;
}
.ai-send-btn {
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
font-size: 12px;
}
.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>