update
This commit is contained in:
28
Makefile
Normal file
28
Makefile
Normal 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."
|
||||||
BIN
backend/__pycache__/config.cpython-312.pyc
Normal file
BIN
backend/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/database.cpython-312.pyc
Normal file
BIN
backend/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/generator.cpython-312.pyc
Normal file
BIN
backend/__pycache__/generator.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/models.cpython-312.pyc
Normal file
BIN
backend/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/routes.cpython-312.pyc
Normal file
BIN
backend/__pycache__/routes.cpython-312.pyc
Normal file
Binary file not shown.
36
backend/config.py
Normal file
36
backend/config.py
Normal 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
70
backend/database.py
Normal 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
147
backend/generator.py
Normal 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
28
backend/main.py
Normal 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
29
backend/models.py
Normal 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
3
backend/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
aiosqlite
|
||||||
98
backend/routes.py
Normal file
98
backend/routes.py
Normal 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
39
frontend/.gitignore
vendored
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
38
frontend/README.md
Normal file
38
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
8
frontend/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
2252
frontend/package-lock.json
generated
Normal file
2252
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal 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
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
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')
|
||||||
23
frontend/vite.config.js
Normal file
23
frontend/vite.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user