This commit is contained in:
Team3
2026-05-25 18:43:17 +02:00
parent 145b3b25d5
commit 1cef392892
29 changed files with 3482 additions and 0 deletions

28
Makefile Normal file
View File

@@ -0,0 +1,28 @@
.PHONY: install dev prod stop remove
install:
sudo apt install -y poppler-utils libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite weasyprint pdf2image
cd frontend && npm install
dev:
@echo "Backend: http://localhost:8000"
@echo "Frontend: http://localhost:5173"
@cd backend && uvicorn main:app --reload --port 8000 &
@cd frontend && npx vite --port 5173
prod:
@echo "Backend: http://localhost:8000"
@cd frontend && npx vite build
@cd backend && uvicorn main:app --host 0.0.0.0 --port 8000
stop:
-@pkill -f "uvicorn main:app" 2>/dev/null
-@pkill -f "vite --port 5173" 2>/dev/null
@echo "Server gestoppt."
remove: stop
@echo "Lösche Datenbank und generierte Dateien..."
rm -f guides.db
rm -rf storage/html/* storage/pdf/*
@echo "Fertig."

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

36
backend/config.py Normal file
View File

@@ -0,0 +1,36 @@
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DOC_DIR = PROJECT_ROOT / "doc"
STORAGE_DIR = PROJECT_ROOT / "storage"
DB_PATH = PROJECT_ROOT / "guides.db"
ALLOWED_FORMATS = [
"OnePager",
"Cheatsheet",
"MiniGuide",
"BeginnerGuide",
"IntermediateGuide",
"ExtendedGuide",
]
FORMAT_META = {
"OnePager": {"pages": "1 Seite", "time": "~5 Min"},
"Cheatsheet": {"pages": "1 Seite", "time": "~10 Min"},
"MiniGuide": {"pages": "3-4 Seiten", "time": "~15 Min"},
"BeginnerGuide": {"pages": "35-40 Seiten", "time": "~3h"},
"IntermediateGuide": {"pages": "42-50 Seiten", "time": "~4h"},
"ExtendedGuide": {"pages": "47-60 Seiten", "time": "~5h"},
}
GENERATION_TIMEOUTS = {
"OnePager": 600,
"Cheatsheet": 600,
"MiniGuide": 600,
"BeginnerGuide": 900,
"IntermediateGuide": 1200,
"ExtendedGuide": 1500,
}
MAX_CONCURRENT_GENERATIONS = 10
CLAUDE_CLI = "claude"

70
backend/database.py Normal file
View File

@@ -0,0 +1,70 @@
import aiosqlite
from config import DB_PATH
CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS guides (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
format TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
progress TEXT,
error_msg TEXT,
html_path TEXT,
pdf_path TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
async def init_db():
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(CREATE_TABLE)
await db.commit()
def _row_to_dict(row, cursor):
columns = [d[0] for d in cursor.description]
return dict(zip(columns, row))
async def create_guide(guide: dict) -> dict:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""INSERT INTO guides (id, topic, format, status, progress, html_path, pdf_path, created_at, updated_at)
VALUES (:id, :topic, :format, :status, :progress, :html_path, :pdf_path, :created_at, :updated_at)""",
guide,
)
await db.commit()
return guide
async def get_guide(guide_id: str) -> dict | None:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("SELECT * FROM guides WHERE id = ?", (guide_id,))
row = await cursor.fetchone()
if row is None:
return None
return _row_to_dict(row, cursor)
async def list_guides() -> list[dict]:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("SELECT * FROM guides ORDER BY created_at DESC")
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def update_guide(guide_id: str, **fields) -> None:
sets = ", ".join(f"{k} = :{k}" for k in fields)
fields["id"] = guide_id
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(f"UPDATE guides SET {sets} WHERE id = :id", fields)
await db.commit()
async def delete_guide(guide_id: str) -> bool:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
await db.commit()
return cursor.rowcount > 0

147
backend/generator.py Normal file
View File

@@ -0,0 +1,147 @@
import asyncio
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from config import (
CLAUDE_CLI,
DOC_DIR,
GENERATION_TIMEOUTS,
MAX_CONCURRENT_GENERATIONS,
STORAGE_DIR,
)
from database import update_guide
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
_active_processes: dict[str, asyncio.subprocess.Process] = {}
async def cancel_guide(guide_id: str) -> bool:
process = _active_processes.get(guide_id)
if process and process.returncode is None:
process.kill()
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now)
return True
return False
def _build_prompt(topic: str, format_name: str, html_path: Path, pdf_path: Path) -> str:
spec = (DOC_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
reference = (DOC_DIR / "Referenz" / f"{format_name}.md").read_text(encoding="utf-8")
return f"""Erstelle einen Lern-Guide zum Thema "{topic}" im Format "{format_name}".
Recherchiere zuerst die aktuelle Version und aktuelle Fakten zu "{topic}" per Websuche, damit Versionsnummern und Angaben stimmen.
Schreibe die HTML-Datei nach: {html_path}
Erstelle die PDF-Datei nach: {pdf_path}
FORMAT-SPEZIFIKATION:
{spec}
REFERENZ-IMPLEMENTIERUNG (Stil-Vorlage, adaptiere für "{topic}"):
{reference}
"""
async def _set_progress(guide_id: str, progress: str) -> None:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, progress=progress, updated_at=now)
async def _watch_files(guide_id: str, html_path: Path, pdf_path: Path, stop_event: asyncio.Event) -> None:
html_seen = False
pdf_mtime = 0.0
iteration = 0
while not stop_event.is_set():
await asyncio.sleep(2)
if not html_seen and html_path.exists():
html_seen = True
await _set_progress(guide_id, "HTML generiert…")
if pdf_path.exists():
current_mtime = pdf_path.stat().st_mtime
if current_mtime > pdf_mtime:
pdf_mtime = current_mtime
iteration += 1
await _set_progress(guide_id, f"Iteration {iteration}")
async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
async with _semaphore:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Lesen…", updated_at=now)
html_path = STORAGE_DIR / "html" / f"{guide_id}.html"
pdf_path = STORAGE_DIR / "pdf" / f"{guide_id}.pdf"
prompt = _build_prompt(topic, format_name, html_path, pdf_path)
await _set_progress(guide_id, "Generiere HTML…")
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
f.write(prompt)
prompt_file = f.name
stop_event = asyncio.Event()
watcher = asyncio.create_task(_watch_files(guide_id, html_path, pdf_path, stop_event))
try:
timeout = GENERATION_TIMEOUTS.get(format_name, 600)
process = await asyncio.create_subprocess_exec(
CLAUDE_CLI,
"-p",
"--allowedTools", "Write,Bash,Read,WebSearch,WebFetch",
"--dangerously-skip-permissions",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_active_processes[guide_id] = process
stdout, stderr = await asyncio.wait_for(
process.communicate(input=prompt.encode("utf-8")),
timeout=timeout,
)
stop_event.set()
await watcher
now = datetime.now(timezone.utc).isoformat()
if process.returncode != 0:
error = stderr.decode("utf-8", errors="replace")[:2000]
await update_guide(guide_id, status="error", progress=None, error_msg=error, updated_at=now)
return
if not html_path.exists():
await update_guide(guide_id, status="error", progress=None, error_msg="HTML-Datei wurde nicht erstellt", updated_at=now)
return
if not pdf_path.exists():
await update_guide(guide_id, status="error", progress=None, error_msg="PDF-Datei wurde nicht erstellt", updated_at=now)
return
await update_guide(
guide_id,
status="done",
progress=None,
html_path=str(html_path),
pdf_path=str(pdf_path),
updated_at=now,
)
except asyncio.TimeoutError:
stop_event.set()
await watcher
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="error", progress=None, error_msg=f"Timeout nach {timeout}s", updated_at=now)
except Exception as e:
stop_event.set()
await watcher
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="error", progress=None, error_msg=str(e)[:2000], updated_at=now)
finally:
_active_processes.pop(guide_id, None)
Path(prompt_file).unlink(missing_ok=True)

28
backend/main.py Normal file
View File

@@ -0,0 +1,28 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import STORAGE_DIR
from database import init_db
from routes import router
@asynccontextmanager
async def lifespan(app: FastAPI):
(STORAGE_DIR / "html").mkdir(parents=True, exist_ok=True)
(STORAGE_DIR / "pdf").mkdir(parents=True, exist_ok=True)
await init_db()
yield
app = FastAPI(title="Guides Generator", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)

29
backend/models.py Normal file
View File

@@ -0,0 +1,29 @@
from pydantic import BaseModel, Field
from typing import Literal
FormatType = Literal[
"OnePager",
"Cheatsheet",
"MiniGuide",
"BeginnerGuide",
"IntermediateGuide",
"ExtendedGuide",
]
class GuideCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
format: FormatType
class GuideResponse(BaseModel):
id: str
topic: str
format: str
status: str
progress: str | None = None
error_msg: str | None = None
html_path: str | None = None
pdf_path: str | None = None
created_at: str
updated_at: str

3
backend/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi
uvicorn[standard]
aiosqlite

98
backend/routes.py Normal file
View File

@@ -0,0 +1,98 @@
import asyncio
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from config import FORMAT_META, STORAGE_DIR
from database import create_guide, delete_guide, get_guide, list_guides
from generator import generate_guide, cancel_guide
from models import GuideCreateRequest, GuideResponse
router = APIRouter(prefix="/api")
@router.get("/formats")
async def get_formats():
return FORMAT_META
@router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest):
now = datetime.now(timezone.utc).isoformat()
guide = {
"id": str(uuid.uuid4()),
"topic": req.topic.strip(),
"format": req.format,
"status": "queued",
"progress": None,
"html_path": None,
"pdf_path": None,
"created_at": now,
"updated_at": now,
}
await create_guide(guide)
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"]))
return guide
@router.get("/guides", response_model=list[GuideResponse])
async def list_all():
return await list_guides()
@router.get("/guides/{guide_id}", response_model=GuideResponse)
async def get_one(guide_id: str):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
return guide
@router.get("/guides/{guide_id}/html")
async def download_html(guide_id: str):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
if guide["status"] != "done" or not guide["html_path"]:
raise HTTPException(404, "HTML nicht verfügbar")
path = Path(guide["html_path"])
if not path.exists():
raise HTTPException(404, "Datei nicht gefunden")
return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.html", media_type="text/html")
@router.post("/guides/{guide_id}/cancel")
async def cancel(guide_id: str):
cancelled = await cancel_guide(guide_id)
if not cancelled:
raise HTTPException(404, "Kein aktiver Prozess gefunden")
return {"ok": True}
@router.get("/guides/{guide_id}/pdf")
async def download_pdf(guide_id: str):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
if guide["status"] != "done" or not guide["pdf_path"]:
raise HTTPException(404, "PDF nicht verfügbar")
path = Path(guide["pdf_path"])
if not path.exists():
raise HTTPException(404, "Datei nicht gefunden")
return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.pdf", media_type="application/pdf")
@router.delete("/guides/{guide_id}")
async def remove(guide_id: str):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
if guide["html_path"]:
Path(guide["html_path"]).unlink(missing_ok=True)
if guide["pdf_path"]:
Path(guide["pdf_path"]).unlink(missing_ok=True)
await delete_guide(guide_id)
return {"ok": True}

39
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

38
frontend/README.md Normal file
View File

@@ -0,0 +1,38 @@
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
frontend/jsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

2252
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.32"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

203
frontend/src/App.vue Normal file
View 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
View 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`
}

View 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>

View 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">&times;</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)"
>
&times;
</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
View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

23
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,23 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})

BIN
guides.db Normal file

Binary file not shown.