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
This commit is contained in:
2026-05-22 20:02:31 +02:00
parent 60a83d90dd
commit 73144d4ef1
+212 -2
View File
@@ -47,6 +47,12 @@
<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>
@@ -79,8 +85,46 @@
<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>
<!-- Preferences Panel -->
<main class="panel" v-if="view === 'prefs'">
<h2>Preferences</h2>
<div class="panel-section">
@@ -147,7 +191,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
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'
@@ -172,6 +216,14 @@ 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' })
@@ -184,6 +236,8 @@ 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')
@@ -246,12 +300,18 @@ async function openFile(path) {
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() {
@@ -335,6 +395,64 @@ function formatDate(d) {
} 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) {
@@ -548,6 +666,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
flex: 1;
display: flex;
flex-direction: column;
position: relative;
}
.toolbar {
@@ -803,6 +922,97 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.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) {