@@ -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+`)" > & lt ; / & g t ; < / b u t t o n >
< 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 : 16 px ; border - radius : 6 px ; overflow - x : auto ; margin : 0 0 16 px ; }
. preview - pane pre code { background : none ; padding : 0 ; font - size : 85 % ; }
. preview - pane ul , . preview - pane ol { padding - left : 2 em ; margin : 0 0 16 px ; }
. preview - pane li { margin : 4 px 0 ; }
. preview - pane blockquote { border - left : 4 px solid var ( -- accent ) ; padding : 0 16 px ; color : var ( -- text - muted ) ; margin : 0 0 16 px ; }
. preview - pane table { border - collapse : collapse ; width : 100 % ; margin : 0 0 16 px ; }
. preview - pane th , . preview - pane td { border : 1 px solid var ( -- border ) ; padding : 8 px 12 px ; text - align : left ; }
. preview - pane th { background : var ( -- code - bg ) ; }
. preview - pane hr { border : none ; border - top : 1 px solid var ( -- border ) ; margin : 24 px 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 : 4 px ; }
. preview - pane input [ type = "checkbox" ] { margin - right : 6 px ; }
/* ─── Login ───────────────────────────────────────────────────────────────── */
. login {
display : flex ;
align - items : center ;
justify - content : center ;
height : 100 vh ;
background : var ( -- bg - primary ) ;
}
. login form {
display : flex ;
flex - direction : column ;
gap : 12 px ;
width : 300 px ;
}
. login h1 { color : var ( -- text ) ; text - align : center ; margin - bottom : 12 px ; }
. login input {
padding : 10 px ;
border - radius : 6 px ;
border : 1 px solid var ( -- border ) ;
background : var ( -- bg - hover ) ;
color : var ( -- text ) ;
font - size : 14 px ;
}
. login button {
padding : 10 px ;
background : var ( -- accent ) ;
color : var ( -- bg - primary ) ;
border : none ;
border - radius : 6 px ;
cursor : pointer ;
font - size : 14 px ;
font - weight : 600 ;
}
. login . error { color : var ( -- danger ) ; text - align : center ; font - size : 13 px ; }
/* ─── Sidebar Nav ─────────────────────────────────────────────────────────── */
. sidebar - nav {
display : flex ;
flex - wrap : wrap ;
gap : 2 px ;
padding : 8 px ;
border - bottom : 1 px solid var ( -- border ) ;
}
. sidebar - nav button {
flex : 1 ;
background : transparent ;
color : var ( -- text - muted ) ;
border : none ;
padding : 6 px 4 px ;
border - radius : 4 px ;
cursor : pointer ;
font - size : 11 px ;
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 : 32 px ;
overflow - y : auto ;
background : var ( -- bg - tertiary ) ;
color : var ( -- text ) ;
}
. panel h2 { margin - bottom : 24 px ; }
. panel h3 { margin : 16 px 0 8 px ; }
. panel - section { margin - bottom : 24 px ; }
. panel - section label { display : block ; margin - bottom : 6 px ; color : var ( -- text - muted ) ; font - size : 13 px ; }
. panel - section select , . panel - section input [ type = "text" ] {
padding : 8 px 12 px ;
background : var ( -- bg - primary ) ;
border : 1 px solid var ( -- border ) ;
border - radius : 4 px ;
color : var ( -- text ) ;
font - size : 14 px ;
min - width : 200 px ;
}
. admin - form {
display : flex ;
flex - wrap : wrap ;
gap : 8 px ;
align - items : center ;
}
. admin - form input {
padding : 8 px 12 px ;
background : var ( -- bg - primary ) ;
border : 1 px solid var ( -- border ) ;
border - radius : 4 px ;
color : var ( -- text ) ;
font - size : 14 px ;
}
. admin - form button {
padding : 8 px 16 px ;
background : var ( -- accent ) ;
color : var ( -- bg - primary ) ;
border : none ;
border - radius : 4 px ;
cursor : pointer ;
}
. admin - msg { margin - top : 8 px ; color : var ( -- success ) ; font - size : 13 px ; }
. user - table { width : 100 % ; border - collapse : collapse ; margin - top : 12 px ; }
. user - table th , . user - table td { padding : 8 px 12 px ; border : 1 px solid var ( -- border ) ; text - align : left ; font - size : 13 px ; }
. user - table th { background : var ( -- bg - hover ) ; }
. file - meta { color : var ( -- text - muted ) ; font - size : 11 px ; }
/* ─── Responsive ──────────────────────────────────────────────────────────── */
@ media ( max - width : 768 px ) {
. sidebar { position : fixed ; left : - 260 px ; z - index : 10 ; transition : left 0.2 s ; }
. sidebar . open { left : 0 ; }
. format - toolbar { display : none ; }
}
< / style >