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:
+212
-2
@@ -47,6 +47,12 @@
|
|||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<span class="file-name">{{ currentFile || 'No file open' }}</span>
|
<span class="file-name">{{ currentFile || 'No file open' }}</span>
|
||||||
<span class="file-meta" v-if="fileMeta">{{ fileMeta }}</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">
|
<div class="export-actions" v-if="currentFile">
|
||||||
<button @click="exportPDF" title="Export PDF">PDF</button>
|
<button @click="exportPDF" title="Export PDF">PDF</button>
|
||||||
<button @click="exportHTML" title="Export HTML">HTML</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>
|
<p v-else style="color:#6c7086;font-style:italic">Preview will appear here...</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<!-- Preferences Panel -->
|
|
||||||
<main class="panel" v-if="view === 'prefs'">
|
<main class="panel" v-if="view === 'prefs'">
|
||||||
<h2>Preferences</h2>
|
<h2>Preferences</h2>
|
||||||
<div class="panel-section">
|
<div class="panel-section">
|
||||||
@@ -147,7 +191,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import FileTree from './components/FileTree.vue'
|
import FileTree from './components/FileTree.vue'
|
||||||
import MilkdownEditor from './components/MilkdownEditor.vue'
|
import MilkdownEditor from './components/MilkdownEditor.vue'
|
||||||
import { api, setToken } from './lib/api.js'
|
import { api, setToken } from './lib/api.js'
|
||||||
@@ -172,6 +216,14 @@ const view = ref('files')
|
|||||||
const isAdmin = ref(false)
|
const isAdmin = ref(false)
|
||||||
const fileMeta = ref('')
|
const fileMeta = ref('')
|
||||||
const sharedFiles = 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
|
// Preferences
|
||||||
const prefs = ref({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, defaultMode: 'split', theme: 'dark' })
|
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))
|
const rendered = computed(() => renderMarkdown(content.value))
|
||||||
|
|
||||||
|
watch(showHistory, (val) => { if (val) loadHistory() })
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
dark.value = !dark.value
|
dark.value = !dark.value
|
||||||
document.documentElement.setAttribute('data-theme', dark.value ? 'dark' : 'light')
|
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)}` : ''
|
fileMeta.value = res.created ? `Created: ${formatDate(res.created)}` : ''
|
||||||
isDirty.value = false
|
isDirty.value = false
|
||||||
view.value = 'files'
|
view.value = 'files'
|
||||||
|
showHistory.value = false
|
||||||
|
showShareDialog.value = false
|
||||||
|
aiResult.value = ''
|
||||||
|
loadHistory()
|
||||||
|
checkGitStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveFile() {
|
async function saveFile() {
|
||||||
if (!currentFile.value) return
|
if (!currentFile.value) return
|
||||||
await api('/api/files/write', { path: currentFile.value, content: content.value })
|
await api('/api/files/write', { path: currentFile.value, content: content.value })
|
||||||
isDirty.value = false
|
isDirty.value = false
|
||||||
|
setTimeout(checkGitStatus, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createFile() {
|
async function createFile() {
|
||||||
@@ -335,6 +395,64 @@ function formatDate(d) {
|
|||||||
} catch { return 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 ──────────────────────────────────────────────────────────────
|
// ─── Formatting ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function insertFormat(before, after) {
|
function insertFormat(before, after) {
|
||||||
@@ -548,6 +666,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -803,6 +922,97 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|||||||
|
|
||||||
.file-meta { color: var(--text-muted); font-size: 11px; }
|
.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 ──────────────────────────────────────────────────────────── */
|
/* ─── Responsive ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user