Initial commit: Phase 1+2 prototype
- Go backend with SQLite, JWT auth, file CRUD - Vue 3 frontend with split/raw/WYSIWYG editor modes - Markdown preview (marked, GFM) - Formatting toolbar + keyboard shortcuts - File tree with search, create, delete - Light/dark theme toggle - Admin panel (user management) - Preferences (timezone, theme, default mode) - Shared documents section (placeholder) - Export: PDF, HTML, MD - Build daemon (Python, stdlib only) - Build job queue API - Docker deployment
This commit is contained in:
@@ -0,0 +1,813 @@
|
||||
<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+`)"></></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>
|
||||
<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>
|
||||
</main>
|
||||
<!-- Preferences Panel -->
|
||||
<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 } 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([])
|
||||
|
||||
// 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))
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
if (!currentFile.value) return
|
||||
await api('/api/files/write', { path: currentFile.value, content: content.value })
|
||||
isDirty.value = false
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
/* ─── 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>
|
||||
Reference in New Issue
Block a user