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">
|
||||
<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) {
|
||||
|
||||
Reference in New Issue
Block a user