update
This commit is contained in:
203
frontend/src/App.vue
Normal file
203
frontend/src/App.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel } from './api.js'
|
||||
import TopicSidebar from './components/TopicSidebar.vue'
|
||||
import TopicDetail from './components/TopicDetail.vue'
|
||||
|
||||
const guides = ref([])
|
||||
const manualTopics = ref([])
|
||||
const selectedTopic = ref(null)
|
||||
const previewGuide = ref(null)
|
||||
let pollTimer = null
|
||||
|
||||
const topics = computed(() => {
|
||||
const topicDates = {}
|
||||
for (const g of guides.value) {
|
||||
if (!topicDates[g.topic] || g.created_at > topicDates[g.topic]) {
|
||||
topicDates[g.topic] = g.created_at
|
||||
}
|
||||
}
|
||||
for (const t of manualTopics.value) {
|
||||
if (!topicDates[t]) topicDates[t] = new Date().toISOString()
|
||||
}
|
||||
return Object.keys(topicDates).sort((a, b) => topicDates[b].localeCompare(topicDates[a]))
|
||||
})
|
||||
|
||||
const guidesByFormat = computed(() => {
|
||||
const map = {}
|
||||
for (const g of guides.value) {
|
||||
if (g.topic !== selectedTopic.value) continue
|
||||
if (!map[g.format] || g.created_at > map[g.format].created_at) {
|
||||
map[g.format] = g
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const hasActiveGuides = computed(() =>
|
||||
guides.value.some((g) => g.status === 'queued' || g.status === 'generating'),
|
||||
)
|
||||
|
||||
async function loadGuides() {
|
||||
try {
|
||||
guides.value = await fetchGuides()
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const FORMAT_ORDER = ['OnePager', 'Cheatsheet', 'MiniGuide', 'BeginnerGuide', 'IntermediateGuide', 'ExtendedGuide']
|
||||
|
||||
function autoPreview() {
|
||||
const map = guidesByFormat.value
|
||||
for (const f of FORMAT_ORDER) {
|
||||
if (map[f]?.status === 'done') {
|
||||
previewGuide.value = map[f]
|
||||
return
|
||||
}
|
||||
}
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
function selectTopic(topic) {
|
||||
selectedTopic.value = topic
|
||||
previewGuide.value = null
|
||||
nextTick(autoPreview)
|
||||
}
|
||||
|
||||
function createTopic(topic) {
|
||||
if (!manualTopics.value.includes(topic)) {
|
||||
manualTopics.value.push(topic)
|
||||
}
|
||||
selectedTopic.value = topic
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
async function handleFormatClick(format) {
|
||||
if (!selectedTopic.value) return
|
||||
await apiCreate(selectedTopic.value, format)
|
||||
await loadGuides()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
function handlePreview(guide) {
|
||||
previewGuide.value = guide
|
||||
}
|
||||
|
||||
async function handleDeleteGuide(guideId) {
|
||||
await deleteGuide(guideId)
|
||||
if (previewGuide.value?.id === guideId) {
|
||||
previewGuide.value = null
|
||||
}
|
||||
await loadGuides()
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(async () => {
|
||||
await loadGuides()
|
||||
if (!hasActiveGuides.value) stopPolling()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(guideId) {
|
||||
await apiCancel(guideId)
|
||||
await loadGuides()
|
||||
}
|
||||
|
||||
async function handleDeleteTopic(topic) {
|
||||
const topicGuides = guides.value.filter((g) => g.topic === topic)
|
||||
for (const g of topicGuides) {
|
||||
await deleteGuide(g.id)
|
||||
}
|
||||
manualTopics.value = manualTopics.value.filter((t) => t !== topic)
|
||||
if (selectedTopic.value === topic) {
|
||||
selectedTopic.value = null
|
||||
previewGuide.value = null
|
||||
}
|
||||
await loadGuides()
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
if (document.hidden) {
|
||||
stopPolling()
|
||||
} else {
|
||||
loadGuides()
|
||||
if (hasActiveGuides.value) startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadGuides()
|
||||
if (!selectedTopic.value && topics.value.length) {
|
||||
selectTopic(topics.value[0])
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<TopicSidebar
|
||||
:topics="topics"
|
||||
:selectedTopic="selectedTopic"
|
||||
:guidesByFormat="guidesByFormat"
|
||||
:allGuides="guides"
|
||||
@select="selectTopic"
|
||||
@create="createTopic"
|
||||
@formatClick="handleFormatClick"
|
||||
@deleteTopic="handleDeleteTopic"
|
||||
@cancelGuide="handleCancel"
|
||||
@deleteGuide="handleDeleteGuide"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
<TopicDetail
|
||||
v-if="selectedTopic"
|
||||
:previewGuide="previewGuide"
|
||||
/>
|
||||
<div v-else class="empty-main">
|
||||
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f8f9fb;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #5a6470;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
36
frontend/src/api.js
Normal file
36
frontend/src/api.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const BASE = '/api'
|
||||
|
||||
export async function fetchGuides() {
|
||||
const res = await fetch(`${BASE}/guides`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchGuide(id) {
|
||||
const res = await fetch(`${BASE}/guides/${id}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createGuide(topic, format) {
|
||||
const res = await fetch(`${BASE}/guides`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function cancelGuide(id) {
|
||||
await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function deleteGuide(id) {
|
||||
await fetch(`${BASE}/guides/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function pdfUrl(id) {
|
||||
return `${BASE}/guides/${id}/pdf`
|
||||
}
|
||||
|
||||
export function htmlUrl(id) {
|
||||
return `${BASE}/guides/${id}/html`
|
||||
}
|
||||
76
frontend/src/components/TopicDetail.vue
Normal file
76
frontend/src/components/TopicDetail.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { pdfUrl, htmlUrl } from '../api.js'
|
||||
|
||||
defineProps({
|
||||
previewGuide: { type: Object, default: null },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail">
|
||||
<div class="preview" v-if="previewGuide">
|
||||
<object :data="pdfUrl(previewGuide.id)" type="application/pdf" class="preview-frame">
|
||||
<p>PDF kann nicht angezeigt werden. <a :href="pdfUrl(previewGuide.id)" target="_blank">Direkt öffnen</a></p>
|
||||
</object>
|
||||
</div>
|
||||
|
||||
<div class="empty-preview" v-else>
|
||||
<p>Guide-Format anklicken um zu generieren oder Vorschau zu öffnen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail {
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.preview {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 1rem;
|
||||
background: #f8f9fb;
|
||||
border-bottom: 1px solid #e2e5e9;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #5a6470;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-link {
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preview-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.empty-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #5a6470;
|
||||
}
|
||||
</style>
|
||||
326
frontend/src/components/TopicSidebar.vue
Normal file
326
frontend/src/components/TopicSidebar.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
topics: { type: Array, required: true },
|
||||
selectedTopic: { type: String, default: null },
|
||||
guidesByFormat: { type: Object, default: () => ({}) },
|
||||
allGuides: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview'])
|
||||
|
||||
const formats = [
|
||||
{ key: 'OnePager', label: 'OnePager' },
|
||||
{ key: 'Cheatsheet', label: 'Cheatsheet' },
|
||||
{ key: 'MiniGuide', label: 'MiniGuide' },
|
||||
{ key: 'BeginnerGuide', label: 'BeginnerGuide' },
|
||||
{ key: 'IntermediateGuide', label: 'IntermediateGuide' },
|
||||
{ key: 'ExtendedGuide', label: 'ExtendedGuide' },
|
||||
]
|
||||
|
||||
const activeGenerations = computed(() => {
|
||||
return props.allGuides
|
||||
.filter((g) => g.status === 'generating' || g.status === 'queued')
|
||||
.map((g) => `${g.topic} – ${g.format}: ${g.progress || 'Wartend…'}`)
|
||||
})
|
||||
|
||||
function guideStatus(format) {
|
||||
const guide = props.guidesByFormat[format]
|
||||
if (!guide) return 'none'
|
||||
return guide.status
|
||||
}
|
||||
|
||||
function handleFormatClick(format) {
|
||||
const guide = props.guidesByFormat[format]
|
||||
if (guide?.status === 'done') {
|
||||
emit('preview', guide)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlay(format) {
|
||||
const guide = props.guidesByFormat[format]
|
||||
if (guide?.status === 'done') {
|
||||
if (!confirm('Guide überschreiben?')) return
|
||||
}
|
||||
emit('formatClick', format)
|
||||
}
|
||||
|
||||
function handleDelete(format) {
|
||||
const guide = props.guidesByFormat[format]
|
||||
if (!guide) return
|
||||
if (guide.status === 'generating' || guide.status === 'queued') {
|
||||
if (!confirm('Generierung abbrechen?')) return
|
||||
emit('cancelGuide', guide.id)
|
||||
} else {
|
||||
if (!confirm('Guide löschen?')) return
|
||||
emit('deleteGuide', guide.id)
|
||||
}
|
||||
}
|
||||
|
||||
const newTopic = ref('')
|
||||
|
||||
function submit() {
|
||||
const t = newTopic.value.trim()
|
||||
if (!t) return
|
||||
emit('create', t)
|
||||
newTopic.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="new-topic">
|
||||
<input
|
||||
v-model="newTopic"
|
||||
placeholder="Neues Thema…"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<button @click="submit" :disabled="!newTopic.trim()">+</button>
|
||||
</div>
|
||||
<ul class="topic-list">
|
||||
<li
|
||||
v-for="t in topics"
|
||||
:key="t"
|
||||
:class="{ active: t === selectedTopic }"
|
||||
@click="emit('select', t)"
|
||||
>
|
||||
<span>{{ t }}</span>
|
||||
<button class="delete-topic" @click.stop="emit('deleteTopic', t)" title="Löschen">×</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="format-section" v-if="selectedTopic">
|
||||
<div class="progress-info" v-if="activeGenerations.length">
|
||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="f in formats"
|
||||
:key="f.key"
|
||||
:class="['format-row', 'fmt-' + guideStatus(f.key)]"
|
||||
>
|
||||
<button class="format-name" @click="handleFormatClick(f.key)">
|
||||
{{ f.label }}
|
||||
</button>
|
||||
<div class="format-actions">
|
||||
<button
|
||||
v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'"
|
||||
class="action-btn play"
|
||||
:title="guideStatus(f.key) === 'done' ? 'Neu generieren' : 'Generieren'"
|
||||
@click="handlePlay(f.key)"
|
||||
>
|
||||
{{ guideStatus(f.key) === 'done' ? '↻' : '▶' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="guideStatus(f.key) !== 'none'"
|
||||
class="action-btn delete"
|
||||
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen' : 'Löschen'"
|
||||
@click="handleDelete(f.key)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e2e5e9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.new-topic {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #e2e5e9;
|
||||
}
|
||||
|
||||
.new-topic input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #d8dde3;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.new-topic input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.new-topic button {
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-topic button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.topic-list {
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #e2e5e9;
|
||||
}
|
||||
|
||||
.topic-list li {
|
||||
padding: 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topic-list li:hover {
|
||||
background: #f5f3ff;
|
||||
}
|
||||
|
||||
.topic-list li.active {
|
||||
background: #ede9fe;
|
||||
color: #4f46e5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delete-topic {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #991b1b;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.topic-list li:hover .delete-topic {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Format section */
|
||||
.format-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
margin-bottom: 0.25rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.format-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.75rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.format-row:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.format-name {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.fmt-done .format-name {
|
||||
color: #065f46;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: #d1fae5;
|
||||
border: 1px solid #34d399;
|
||||
}
|
||||
|
||||
.fmt-done .format-name:hover {
|
||||
background: #a7f3d0;
|
||||
}
|
||||
|
||||
.fmt-generating .format-name,
|
||||
.fmt-queued .format-name {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fbbf24;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fmt-error .format-name {
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #f87171;
|
||||
}
|
||||
|
||||
.format-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn.play {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.action-btn.play:hover {
|
||||
background: #d1fae5;
|
||||
border-color: #34d399;
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
color: #991b1b;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background: #fee2e2;
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
</style>
|
||||
4
frontend/src/main.js
Normal file
4
frontend/src/main.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
Reference in New Issue
Block a user