Files
markdown-hub/frontend/src/App.vue
T
anders 73144d4ef1 Wire up frontend: git status, history, sharing, AI verify
- Git sync button (green/red indicator) in toolbar
- History panel: view commits, click to restore
- Share dialog: share files with other users by username
- AI Verify button: sends spec to LiteLLM for review
- AI response panel with rendered markdown
- Auto-refresh git status on file open/save
- Watch for history panel open to load commits
2026-05-22 20:02:31 +02:00

1024 lines
35 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.
<template>
<div class="app" v-if="authenticated">
<aside class="sidebar">
<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'">📄 My Files</button>
<button :class="{active: view === 'shared'}" @click="view = 'shared'">🤝 Shared</button>
<button :class="{active: view === 'prefs'}" @click="view = 'prefs'"> Preferences</button>
<button v-if="isAdmin" :class="{active: view === 'admin'}" @click="view = 'admin'">👤 Admin</button>
</div>
<FileTree v-if="view === 'files'" :files="filteredFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" />
<FileTree v-if="view === 'shared'" :files="sharedFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" />
</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'">WYSIWYG</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">
<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 class="file-meta" v-if="fileMeta">{{ fileMeta }}</span>
<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}" @click="saveFile">
{{ 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="isDirty = true"
@keydown="handleKeydown"
placeholder="Start writing markdown..."
></textarea>
</div>
<div v-if="mode === 'wysiwyg'" class="wysiwyg-pane">
<MilkdownEditor
:key="currentFile"
v-model="content"
@update:modelValue="isDirty = true"
/>
</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>
</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>
</main>
</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'
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('')
// 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']
// Admin
const users = ref([])
const newUser = ref({ username: '', email: '', password: '', isAdmin: false })
const adminMsg = 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()
if (isAdmin.value) loadUsers()
} catch (e) {
loginError.value = 'Invalid credentials'
}
}
// ─── Files ───────────────────────────────────────────────────────────────────
async function loadFiles() {
fileTree.value = await api('/api/files/list', {})
filteredFiles.value = fileTree.value
}
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
const res = await api('/api/files/read', { path })
currentFile.value = path
content.value = res.content
fileMeta.value = res.created ? `Created: ${formatDate(res.created)}` : ''
isDirty.value = false
view.value = 'files'
showHistory.value = false
showShareDialog.value = false
aiResult.value = ''
loadHistory()
checkGitStatus()
}
async function saveFile() {
if (!currentFile.value) return
await api('/api/files/write', { path: currentFile.value, content: content.value })
isDirty.value = false
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()
}
// ─── Shared ──────────────────────────────────────────────────────────────────
async function loadShared() {
try {
sharedFiles.value = await api('/api/files/shared', {})
} catch (e) {
sharedFiles.value = []
}
}
// ─── 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()
}
// ─── 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'
}
}
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.'
}
}
// ─── 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; }
: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;
}
/* ─── 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; }
.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-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; }
/* ─── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.sidebar { position: fixed; left: -260px; z-index: 10; transition: left 0.2s; }
.sidebar.open { left: 0; }
.format-toolbar { display: none; }
}
</style>