Complete TODO items: security, features, polish
Security: - Encrypt Gitea tokens at rest (AES-256-GCM with MH_SECRET) - Secure cookie flag when behind HTTPS (X-Forwarded-Proto) - Password complexity (min 8 chars) - TOTP: defer persist until verified (totp_pending column) - Audit log table + logging on login/rename/password change Features: - Rename files/folders (double-click in tree, /api/files/rename) - beforeunload warning for unsaved changes - Mobile hamburger menu - PWA icons (192px, 512px) - Max file size enforcement (10MB) - Shared file read access (cross-user with permission check) Polish: - Toast notifications replace all alert() calls - Keyboard shortcut help overlay (Ctrl+/) - File rename via double-click in FileTree
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 546 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
+144
-13
@@ -1,6 +1,11 @@
|
||||
<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">
|
||||
<aside class="sidebar">
|
||||
<button class="hamburger" @click="sidebarOpen = !sidebarOpen">☰</button>
|
||||
<aside class="sidebar" :class="{open: sidebarOpen}">
|
||||
<div class="sidebar-header">
|
||||
<h2>MarkdownHub</h2>
|
||||
<div class="sidebar-actions">
|
||||
@@ -21,7 +26,7 @@
|
||||
<button @click="logout" title="Logout">🚪</button>
|
||||
<button @click="view = 'about'" title="About">ℹ️</button>
|
||||
</div>
|
||||
<FileTree v-if="view === 'files'" :files="filteredFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" @move="moveFile" />
|
||||
<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="openFile" @delete="deleteItem" @move="moveFile" />
|
||||
<div v-if="view === 'trash'" class="trash-view">
|
||||
<div class="trash-header">
|
||||
@@ -250,6 +255,22 @@
|
||||
<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">
|
||||
@@ -300,6 +321,32 @@ 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)
|
||||
|
||||
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' })
|
||||
@@ -424,7 +471,7 @@ async function openFile(path) {
|
||||
content.value = cached
|
||||
fileMeta.value = '(offline cache)'
|
||||
} else {
|
||||
alert('File unavailable offline')
|
||||
toast('File unavailable offline', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -512,7 +559,22 @@ async function moveFile({ from, to }) {
|
||||
}
|
||||
await loadFiles()
|
||||
} catch (e) {
|
||||
alert('Move failed')
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +639,7 @@ async function setupTOTP() {
|
||||
totpUri.value = res.uri
|
||||
totpSecret.value = res.secret
|
||||
} catch (e) {
|
||||
alert('Failed to setup 2FA')
|
||||
toast('Failed to setup 2FA', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,9 +650,9 @@ async function verifyTOTP() {
|
||||
totpUri.value = ''
|
||||
totpSecret.value = ''
|
||||
totpCode.value = ''
|
||||
alert('2FA enabled!')
|
||||
toast('2FA enabled!', 'success')
|
||||
} catch (e) {
|
||||
alert('Invalid code')
|
||||
toast('Invalid code', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,9 +661,9 @@ async function disableTOTP() {
|
||||
await api('/api/auth/totp/disable', { code: totpCode.value })
|
||||
totpEnabled.value = false
|
||||
totpCode.value = ''
|
||||
alert('2FA disabled')
|
||||
toast('2FA disabled', 'success')
|
||||
} catch (e) {
|
||||
alert('Invalid code')
|
||||
toast('Invalid code', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,21 +685,21 @@ async function addRemote() {
|
||||
async function gitPush() {
|
||||
try {
|
||||
await api('/api/git/push', { remote: gitRemotes.value[0]?.name || 'origin' })
|
||||
alert('Pushed successfully')
|
||||
toast('Pushed successfully', 'success')
|
||||
checkGitStatus()
|
||||
} catch (e) {
|
||||
alert('Push failed')
|
||||
toast('Push failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function gitPull() {
|
||||
try {
|
||||
await api('/api/git/pull', { remote: gitRemotes.value[0]?.name || 'origin' })
|
||||
alert('Pulled successfully')
|
||||
toast('Pulled successfully', 'success')
|
||||
loadFiles()
|
||||
checkGitStatus()
|
||||
} catch (e) {
|
||||
alert('Pull failed')
|
||||
toast('Pull failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1326,9 +1388,78 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
|
||||
/* ─── 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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="tree-node"
|
||||
:class="{ selected: item.path === selected, folder: item.isDir, 'drop-target': dropTarget === item.path }"
|
||||
@click="item.isDir ? toggleFolder(item) : $emit('select', item.path)"
|
||||
@contextmenu.prevent="null"
|
||||
@dblclick.stop="$emit('rename', item)"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, item)"
|
||||
@dragover.prevent="onDragOver($event, item)"
|
||||
@@ -17,7 +17,7 @@
|
||||
<button class="delete-btn" @click.stop="$emit('delete', item)" title="Delete">×</button>
|
||||
</div>
|
||||
<div v-if="item.isDir && expanded[item.path] && item.children" class="children">
|
||||
<FileTree :files="item.children" :selected="selected" @select="$emit('select', $event)" @delete="$emit('delete', $event)" @move="$emit('move', $event)" />
|
||||
<FileTree :files="item.children" :selected="selected" @select="$emit('select', $event)" @delete="$emit('delete', $event)" @move="$emit('move', $event)" @rename="$emit('rename', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@ defineProps({
|
||||
selected: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'delete', 'move'])
|
||||
const emit = defineEmits(['select', 'delete', 'move', 'rename'])
|
||||
|
||||
const expanded = reactive({})
|
||||
const dropTarget = ref('')
|
||||
@@ -60,7 +60,6 @@ function onDrop(e, targetItem) {
|
||||
dropTarget.value = ''
|
||||
const sourcePath = e.dataTransfer.getData('text/plain')
|
||||
if (!sourcePath || !targetItem.isDir || sourcePath === targetItem.path) return
|
||||
// Don't drop into itself
|
||||
if (targetItem.path.startsWith(sourcePath + '/')) return
|
||||
emit('move', { from: sourcePath, to: targetItem.path })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user