Compare commits
41 Commits
145b3b25d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5152f4f309 | ||
|
|
ab85cb530f | ||
|
|
5a794cc40c | ||
|
|
1aef82ec40 | ||
|
|
d08878289e | ||
|
|
614bce2c89 | ||
|
|
c73d07be52 | ||
|
|
3ce313932d | ||
|
|
6472cbf1d3 | ||
|
|
40de56c27b | ||
|
|
d7e8df6876 | ||
|
|
cb088c9d2d | ||
|
|
e7af2b1150 | ||
|
|
b591897fd6 | ||
|
|
bd4639b3aa | ||
|
|
aedb44ac6e | ||
|
|
7b158dce4e | ||
|
|
8df6e5b2ed | ||
|
|
d4f4f39c32 | ||
|
|
d1871234bb | ||
|
|
7507bed55c | ||
|
|
03a388864c | ||
|
|
a91493d533 | ||
|
|
19280f7346 | ||
|
|
067d7229de | ||
|
|
a826e9f6b3 | ||
|
|
5cf0822f5a | ||
|
|
cf9f854dbf | ||
|
|
96536498d0 | ||
|
|
4594c2e372 | ||
|
|
c1370a9f6a | ||
|
|
cc1ea166c8 | ||
|
|
81913f3d8d | ||
|
|
6a39dc7ee2 | ||
|
|
351f330db0 | ||
|
|
ad2f3e4786 | ||
|
|
8fa8f9fb27 | ||
|
|
619bac34cb | ||
|
|
e964c807d9 | ||
|
|
66a48759b3 | ||
|
|
1cef392892 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
frontend/dist
|
||||||
|
storage
|
||||||
|
projects
|
||||||
|
guides.db
|
||||||
|
.git
|
||||||
|
.claude-data
|
||||||
7
.env copy.example
Normal file
7
.env copy.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Datei nach .env kopieren (wird nicht committet).
|
||||||
|
|
||||||
|
# Claude-Provider: lokal einmal 'claude setup-token' ausführen, Token eintragen.
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN=
|
||||||
|
|
||||||
|
# MiniMax-Provider: API-Key aus der MiniMax-Console (Coding-Plan).
|
||||||
|
MINIMAX_API_KEY=
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
storage/
|
||||||
|
projects/
|
||||||
|
guides.db
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.claude-data/
|
||||||
|
.env
|
||||||
45
Dockerfile
Normal file
45
Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Stage 1: Frontend bauen
|
||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
poppler-utils \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangoft2-1.0-0 \
|
||||||
|
libharfbuzz0b \
|
||||||
|
libcairo2 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libffi-dev \
|
||||||
|
fonts-dejavu \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
gnupg \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& npm install -g @anthropic-ai/claude-code opencode-ai \
|
||||||
|
&& pip install --no-cache-dir uv \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN useradd -m -u 1000 app
|
||||||
|
|
||||||
|
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||||
|
|
||||||
|
COPY --chown=app:app backend/ /app/backend/
|
||||||
|
COPY --chown=app:app templates/ /app/templates/
|
||||||
|
COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist
|
||||||
|
COPY --chown=app:app dev-ops/opencode.json /home/app/.config/opencode/opencode.json
|
||||||
|
|
||||||
|
RUN chown app:app /app
|
||||||
|
|
||||||
|
USER app
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
56
Makefile
Normal file
56
Makefile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.PHONY: install dev prod stop logs remove auth sync
|
||||||
|
|
||||||
|
COMPOSE = docker compose
|
||||||
|
|
||||||
|
auth:
|
||||||
|
@mkdir -p .claude-data storage projects
|
||||||
|
@chown -R 1000:1000 .claude-data storage projects
|
||||||
|
@grep -q "^CLAUDE_CODE_OAUTH_TOKEN=.\+" .env 2>/dev/null \
|
||||||
|
|| echo "WARNUNG: CLAUDE_CODE_OAUTH_TOKEN fehlt in .env (Claude-Provider inaktiv)."
|
||||||
|
@grep -q "^MINIMAX_API_KEY=.\+" .env 2>/dev/null \
|
||||||
|
|| echo "WARNUNG: MINIMAX_API_KEY fehlt in .env (MiniMax-Provider inaktiv)."
|
||||||
|
@echo "Verzeichnisse angelegt und auf uid 1000 chowned."
|
||||||
|
|
||||||
|
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 uv
|
||||||
|
cd frontend && npm install
|
||||||
|
npm install -g opencode-ai
|
||||||
|
@mkdir -p $(HOME)/.config/opencode
|
||||||
|
@ln -sfn $(CURDIR)/dev-ops/opencode.json $(HOME)/.config/opencode/opencode.json
|
||||||
|
@if grep -q "^MINIMAX_API_KEY=.\+" .env 2>/dev/null; then \
|
||||||
|
echo "OpenCode-Config verlinkt. MINIMAX_API_KEY aus .env wird von 'make dev' geladen."; \
|
||||||
|
else \
|
||||||
|
echo "OpenCode-Config verlinkt. MINIMAX_API_KEY noch in .env eintragen."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@echo "Backend: http://localhost:8000"
|
||||||
|
@echo "Frontend: http://localhost:5173"
|
||||||
|
@set -a; [ -f .env ] && . ./.env; set +a; \
|
||||||
|
cd backend && uvicorn main:app --reload --port 8000 &
|
||||||
|
@cd frontend && npx vite --port 5173
|
||||||
|
|
||||||
|
prod: auth
|
||||||
|
$(COMPOSE) up -d --build
|
||||||
|
|
||||||
|
stop:
|
||||||
|
-@pkill -f "uvicorn main:app" 2>/dev/null
|
||||||
|
-@pkill -f "vite --port 5173" 2>/dev/null
|
||||||
|
$(COMPOSE) down
|
||||||
|
@echo "Server gestoppt."
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
|
remove: stop
|
||||||
|
@echo "Lösche Datenbank und generierte Dateien..."
|
||||||
|
rm -rf storage/*
|
||||||
|
@echo "Fertig."
|
||||||
|
|
||||||
|
sync:
|
||||||
|
@mkdir -p storage/html storage/pdf
|
||||||
|
rsync -avz --progress root@178.104.67.87:/var/www/guides/storage/guides.db storage/
|
||||||
|
rsync -avz --progress --delete root@178.104.67.87:/var/www/guides/storage/html/ storage/html/
|
||||||
|
rsync -avz --progress --delete root@178.104.67.87:/var/www/guides/storage/pdf/ storage/pdf/
|
||||||
|
@echo "Sync abgeschlossen."
|
||||||
139
backend/agents.py
Normal file
139
backend/agents.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Provider-Schicht: führt Agent-Aufrufe über die Claude-CLI oder OpenCode (MiniMax) aus.
|
||||||
|
|
||||||
|
Beide Runner sind unabhängig. Fehlt ein Binary/Key, schlägt nur der
|
||||||
|
jeweilige Provider fehl — der andere läuft unverändert weiter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import PROVIDERS, DEFAULT_PROVIDER
|
||||||
|
|
||||||
|
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||||
|
|
||||||
|
# Capability → Claude --allowedTools
|
||||||
|
_CLAUDE_TOOLS = {
|
||||||
|
"full": "Write,Bash,Read,WebSearch,WebFetch",
|
||||||
|
"files": "Read,Bash,Write",
|
||||||
|
"read": "Read",
|
||||||
|
"none": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Capability → OpenCode-Agent (Tool-Rechte in dev-ops/opencode.json definiert)
|
||||||
|
_OPENCODE_AGENTS = {
|
||||||
|
"full": "full",
|
||||||
|
"files": "files",
|
||||||
|
"read": "readonly",
|
||||||
|
"none": "text",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def provider_available(provider: str) -> bool:
|
||||||
|
cfg = PROVIDERS.get(provider)
|
||||||
|
if not cfg:
|
||||||
|
return False
|
||||||
|
if shutil.which(cfg["cli"]) is None:
|
||||||
|
return False
|
||||||
|
env_key = cfg.get("env_key")
|
||||||
|
if env_key and not os.environ.get(env_key):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def kill_process(agent_key: str) -> None:
|
||||||
|
process = _active_processes.get(agent_key)
|
||||||
|
if process and process.returncode is None:
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_agent(
|
||||||
|
agent_key: str,
|
||||||
|
prompt: str,
|
||||||
|
timeout: int,
|
||||||
|
provider: str = DEFAULT_PROVIDER,
|
||||||
|
role: str = "fast",
|
||||||
|
capabilities: str = "none",
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
return 1, "", f"Unbekannter Provider: {provider}"
|
||||||
|
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||||
|
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
|
||||||
|
timeout = int(timeout * PROVIDERS[provider].get("timeout_factor", 1))
|
||||||
|
if provider == "minimax":
|
||||||
|
return await _run_opencode(agent_key, prompt, timeout, role, capabilities)
|
||||||
|
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
|
||||||
|
|
||||||
|
|
||||||
|
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int) -> tuple[int, str, str]:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_active_processes[agent_key] = process
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
process.communicate(input=stdin_data),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
||||||
|
finally:
|
||||||
|
_active_processes.pop(agent_key, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]:
|
||||||
|
cfg = PROVIDERS["claude"]
|
||||||
|
cmd = [cfg["cli"], "-p", "--model", cfg[role]]
|
||||||
|
tools = _CLAUDE_TOOLS.get(capabilities)
|
||||||
|
if tools:
|
||||||
|
cmd += ["--allowedTools", tools]
|
||||||
|
cmd += ["--dangerously-skip-permissions"]
|
||||||
|
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_opencode(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]:
|
||||||
|
cfg = PROVIDERS["minimax"]
|
||||||
|
# Prompt über Tempdatei statt argv (ARG_MAX-Schutz bei großen Projekt-Prompts)
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
|
||||||
|
f.write(prompt)
|
||||||
|
prompt_path = Path(f.name)
|
||||||
|
# Positional-Message MUSS vor -f stehen: -f ist ein Array-Flag und
|
||||||
|
# frisst sonst den Text als zweiten Dateinamen ("File not found").
|
||||||
|
cmd = [
|
||||||
|
cfg["cli"], "run",
|
||||||
|
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
|
||||||
|
"-m", cfg[role],
|
||||||
|
"--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"-f", str(prompt_path),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout)
|
||||||
|
return rc, _clean_opencode_output(stdout), stderr
|
||||||
|
finally:
|
||||||
|
prompt_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_opencode_output(text: str) -> str:
|
||||||
|
"""Entfernt ANSI-Codes und den führenden Banner ("> agent · modell")."""
|
||||||
|
text = _ANSI_RE.sub("", text)
|
||||||
|
lines = text.splitlines()
|
||||||
|
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):
|
||||||
|
lines.pop(0)
|
||||||
|
return "\n".join(lines).strip()
|
||||||
49
backend/config.py
Normal file
49
backend/config.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
TEMPLATES_DIR = PROJECT_ROOT / "templates"
|
||||||
|
STORAGE_DIR = PROJECT_ROOT / "storage"
|
||||||
|
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
|
||||||
|
DB_PATH = STORAGE_DIR / "guides.db"
|
||||||
|
PROJECTS_DIR = PROJECT_ROOT / "projects"
|
||||||
|
PROJECTS_CACHE_DIR = STORAGE_DIR / "projects"
|
||||||
|
|
||||||
|
ALLOWED_FORMATS = [
|
||||||
|
"OnePager",
|
||||||
|
"Cheatsheet",
|
||||||
|
"MiniGuide",
|
||||||
|
"Guide",
|
||||||
|
"EndGuide",
|
||||||
|
]
|
||||||
|
|
||||||
|
FORMAT_META = {
|
||||||
|
"OnePager": {"pages": "1 Seite", "time": "~5 Min"},
|
||||||
|
"Cheatsheet": {"pages": "1 Seite", "time": "~10 Min"},
|
||||||
|
"MiniGuide": {"pages": "3-5 Seiten", "time": "~15-25 Min"},
|
||||||
|
"Guide": {"pages": "10-30 Seiten", "time": "variabel"},
|
||||||
|
"EndGuide": {"pages": "50-250 Seiten", "time": "variabel"},
|
||||||
|
}
|
||||||
|
|
||||||
|
AGENT_TIMEOUT = 3600
|
||||||
|
|
||||||
|
MAX_CONCURRENT_GENERATIONS = 10
|
||||||
|
|
||||||
|
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
|
||||||
|
# Rollen: "guide" = große Generierung/Review, "fast" = Bausteine/Chat/Sortierung.
|
||||||
|
DEFAULT_PROVIDER = "claude"
|
||||||
|
PROVIDERS = {
|
||||||
|
"claude": {
|
||||||
|
"cli": "claude",
|
||||||
|
"guide": "claude-opus-4-8[1m]",
|
||||||
|
"fast": "claude-sonnet-4-6",
|
||||||
|
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
|
||||||
|
"timeout_factor": 1,
|
||||||
|
},
|
||||||
|
"minimax": {
|
||||||
|
"cli": "opencode",
|
||||||
|
"guide": "minimax/MiniMax-M3",
|
||||||
|
"fast": "minimax/MiniMax-M3",
|
||||||
|
"env_key": "MINIMAX_API_KEY",
|
||||||
|
"timeout_factor": 3, # M3 ist bei großen Dokumenten deutlich langsamer
|
||||||
|
},
|
||||||
|
}
|
||||||
293
backend/database.py
Normal file
293
backend/database.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import aiosqlite
|
||||||
|
from config import DB_PATH, STORAGE_DIR
|
||||||
|
from paths import final_paths
|
||||||
|
|
||||||
|
CREATE_GUIDES = """
|
||||||
|
CREATE TABLE IF NOT EXISTS guides (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
format TEXT NOT NULL,
|
||||||
|
instructions TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
progress TEXT,
|
||||||
|
error_msg TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
CREATE_BAUSTEINE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS bausteine (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
purpose TEXT NOT NULL DEFAULT '',
|
||||||
|
example TEXT NOT NULL DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
CREATE_SUGGESTIONS = """
|
||||||
|
CREATE TABLE IF NOT EXISTS baustein_suggestions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
purpose TEXT NOT NULL DEFAULT '',
|
||||||
|
example TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
CREATE_PROGRESS = """
|
||||||
|
CREATE TABLE IF NOT EXISTS guide_progress (
|
||||||
|
guide_id TEXT NOT NULL,
|
||||||
|
chapter TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (guide_id, chapter)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_db: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> aiosqlite.Connection:
|
||||||
|
global _db
|
||||||
|
if _db is None:
|
||||||
|
_db = await aiosqlite.connect(DB_PATH)
|
||||||
|
_db.row_factory = None
|
||||||
|
return _db
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(CREATE_GUIDES)
|
||||||
|
await db.execute(CREATE_BAUSTEINE)
|
||||||
|
await db.execute(CREATE_SUGGESTIONS)
|
||||||
|
await db.execute(CREATE_PROGRESS)
|
||||||
|
cursor = await db.execute("PRAGMA table_info(guides)")
|
||||||
|
columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
if "instructions" not in columns:
|
||||||
|
await db.execute("ALTER TABLE guides ADD COLUMN instructions TEXT NOT NULL DEFAULT ''")
|
||||||
|
cursor = await db.execute("PRAGMA table_info(bausteine)")
|
||||||
|
columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
if "sort_order" not in columns:
|
||||||
|
await db.execute("ALTER TABLE bausteine ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
||||||
|
"WHERE status IN ('queued', 'generating')"
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await _migrate_uuid_filenames(db)
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_uuid_filenames(db: aiosqlite.Connection) -> None:
|
||||||
|
cursor = await db.execute("SELECT id, topic, format FROM guides WHERE status = 'done'")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
for guide_id, topic, format_name in rows:
|
||||||
|
final_html, final_pdf = final_paths(topic, format_name)
|
||||||
|
old_html = STORAGE_DIR / "html" / f"{guide_id}.html"
|
||||||
|
old_pdf = STORAGE_DIR / "pdf" / f"{guide_id}.pdf"
|
||||||
|
if old_html.exists() and not final_html.exists():
|
||||||
|
old_html.rename(final_html)
|
||||||
|
if old_pdf.exists() and not final_pdf.exists():
|
||||||
|
old_pdf.rename(final_pdf)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db():
|
||||||
|
global _db
|
||||||
|
if _db is not None:
|
||||||
|
await _db.close()
|
||||||
|
_db = None
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO guides (id, topic, format, instructions, status, progress, created_at, updated_at)
|
||||||
|
VALUES (:id, :topic, :format, :instructions, :status, :progress, :created_at, :updated_at)""",
|
||||||
|
guide,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return guide
|
||||||
|
|
||||||
|
|
||||||
|
async def get_guide(guide_id: str) -> dict | None:
|
||||||
|
db = await get_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]:
|
||||||
|
db = await get_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
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(f"UPDATE guides SET {sets} WHERE id = :id", fields)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_guide(guide_id: str) -> bool:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bausteine ---
|
||||||
|
|
||||||
|
async def create_baustein(baustein: dict) -> dict:
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO bausteine (id, topic, title, description, purpose, example, created_at, updated_at)
|
||||||
|
VALUES (:id, :topic, :title, :description, :purpose, :example, :created_at, :updated_at)""",
|
||||||
|
baustein,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return baustein
|
||||||
|
|
||||||
|
|
||||||
|
async def list_bausteine(topic: str) -> list[dict]:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM bausteine WHERE topic = ? ORDER BY sort_order ASC, created_at ASC", (topic,)
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_dict(row, cursor) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_baustein_sort_orders(topic: str, order_map: dict) -> None:
|
||||||
|
db = await get_db()
|
||||||
|
for baustein_id, order in order_map.items():
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE bausteine SET sort_order = ? WHERE id = ? AND topic = ?",
|
||||||
|
(order, baustein_id, topic),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_baustein(baustein_id: str) -> dict | None:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute("SELECT * FROM bausteine WHERE id = ?", (baustein_id,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _row_to_dict(row, cursor)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_baustein(baustein_id: str, **fields) -> None:
|
||||||
|
sets = ", ".join(f"{k} = :{k}" for k in fields)
|
||||||
|
fields["id"] = baustein_id
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(f"UPDATE bausteine SET {sets} WHERE id = :id", fields)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_baustein(baustein_id: str) -> bool:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute("DELETE FROM bausteine WHERE id = ?", (baustein_id,))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Baustein Suggestions ---
|
||||||
|
|
||||||
|
async def create_suggestions(suggestions: list[dict]) -> None:
|
||||||
|
db = await get_db()
|
||||||
|
await db.executemany(
|
||||||
|
"""INSERT INTO baustein_suggestions (id, topic, title, description, purpose, example, status, created_at)
|
||||||
|
VALUES (:id, :topic, :title, :description, :purpose, :example, :status, :created_at)""",
|
||||||
|
suggestions,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_suggestions(topic: str) -> list[dict]:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM baustein_suggestions WHERE topic = ? ORDER BY created_at ASC", (topic,)
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_dict(row, cursor) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_suggestion(suggestion_id: str) -> dict | None:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute("SELECT * FROM baustein_suggestions WHERE id = ?", (suggestion_id,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _row_to_dict(row, cursor)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_suggestion(suggestion_id: str, **fields) -> None:
|
||||||
|
sets = ", ".join(f"{k} = :{k}" for k in fields)
|
||||||
|
fields["id"] = suggestion_id
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(f"UPDATE baustein_suggestions SET {sets} WHERE id = :id", fields)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_suggestion(suggestion_id: str) -> bool:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute("DELETE FROM baustein_suggestions WHERE id = ?", (suggestion_id,))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_pending_suggestions(topic: str) -> None:
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM baustein_suggestions WHERE topic = ? AND status = 'pending'", (topic,)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kapitel-Fortschritt ---
|
||||||
|
|
||||||
|
async def list_progress(guide_id: str) -> list[str]:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT chapter FROM guide_progress WHERE guide_id = ?", (guide_id,)
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def set_progress(guide_id: str, chapter: str, done: bool) -> None:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
db = await get_db()
|
||||||
|
if done:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR IGNORE INTO guide_progress (guide_id, chapter, created_at) VALUES (?, ?, ?)",
|
||||||
|
(guide_id, chapter, datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM guide_progress WHERE guide_id = ? AND chapter = ?", (guide_id, chapter)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_progress(guide_id: str) -> None:
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,))
|
||||||
|
await db.commit()
|
||||||
711
backend/generator.py
Normal file
711
backend/generator.py
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agents import run_agent, kill_process
|
||||||
|
from config import (
|
||||||
|
AGENT_TIMEOUT,
|
||||||
|
DEFAULT_PROVIDER,
|
||||||
|
TEMPLATES_DIR,
|
||||||
|
MAX_CONCURRENT_GENERATIONS,
|
||||||
|
STORAGE_DIR,
|
||||||
|
)
|
||||||
|
from database import (
|
||||||
|
update_guide,
|
||||||
|
create_baustein,
|
||||||
|
create_suggestions,
|
||||||
|
delete_pending_suggestions,
|
||||||
|
list_bausteine,
|
||||||
|
update_baustein,
|
||||||
|
update_baustein_sort_orders,
|
||||||
|
)
|
||||||
|
from paths import final_paths, temp_paths, project_dir, project_cache_path
|
||||||
|
|
||||||
|
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
|
||||||
|
_cancelled: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_guide(guide_id: str) -> bool:
|
||||||
|
_cancelled.add(guide_id)
|
||||||
|
kill_process(guide_id)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
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 _render_pdf(html_path: Path, pdf_path: Path) -> tuple[bool, str]:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"weasyprint", str(html_path), str(pdf_path),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return False, stderr.decode("utf-8", errors="replace")[:1000]
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_project_index_prompt(name: str, cache_path: Path, has_cache: bool) -> str:
|
||||||
|
src = project_dir(name)
|
||||||
|
if has_cache:
|
||||||
|
return f"""Es existiert bereits eine verdichtete Wissensdatei zum Projekt "{name}" unter {cache_path}.
|
||||||
|
|
||||||
|
Prüfe sie gegen das echte Projekt unter {src}.
|
||||||
|
|
||||||
|
SCHRITT 1: Lies die bestehende Wissensdatei {cache_path}.
|
||||||
|
SCHRITT 2: Verschaffe dir mit Bash (ls/find) und Read einen vollständigen Überblick über {src}. Lies README, Doku-Ordner und den relevanten Quellcode.
|
||||||
|
SCHRITT 3: Ergänze fehlende oder veraltete wichtige Informationen. Ein früherer Lauf war evtl. schlampig und hat Wichtiges ausgelassen.
|
||||||
|
SCHRITT 4: Schreibe die vollständige, korrigierte Wissensdatei zurück nach {cache_path}.
|
||||||
|
|
||||||
|
Die Datei muss als alleinige Faktenbasis für einen Lern-Guide ausreichen. Erfasse: Zweck, Architektur, Abläufe, wichtige Dateien, Konfiguration, Befehle, Datenstrukturen, Besonderheiten. Schreibe NUR diese eine Datei."""
|
||||||
|
|
||||||
|
return f"""Lies das Projekt "{name}" vollständig ein und erstelle daraus eine verdichtete Wissensdatei.
|
||||||
|
|
||||||
|
SCHRITT 1: Verschaffe dir mit Bash (ls/find) einen Überblick über {src}.
|
||||||
|
SCHRITT 2: Lies README, Doku-Ordner und den relevanten Quellcode mit dem Read-Tool.
|
||||||
|
SCHRITT 3: Schreibe eine vollständige Wissensdatei nach {cache_path}.
|
||||||
|
|
||||||
|
Die Datei muss als alleinige Faktenbasis für einen Lern-Guide ausreichen. Erfasse: Zweck, Architektur, Abläufe, wichtige Dateien, Konfiguration, Befehle, Datenstrukturen, Besonderheiten. Lass nichts Wichtiges aus. Schreibe NUR diese eine Datei."""
|
||||||
|
|
||||||
|
|
||||||
|
def _research_line(topic: str, project_content: str | None) -> str:
|
||||||
|
if project_content:
|
||||||
|
return (
|
||||||
|
f'Die folgenden PROJEKT-INHALTE sind die Quelle der Wahrheit für "{topic}". '
|
||||||
|
"Nutze sie als primäre Faktenbasis. Recherchiere per Websuche nur ergänzend, "
|
||||||
|
"um fehlende oder sich ändernde Fakten (z. B. aktuelle Versionsnummern externer Tools) zu prüfen.\n\n"
|
||||||
|
f"PROJEKT-INHALTE (Quelle der Wahrheit):\n{project_content}"
|
||||||
|
)
|
||||||
|
return f'Recherchiere zuerst die aktuelle Version und aktuelle Fakten zu "{topic}" per Websuche, damit Versionsnummern und Angaben stimmen.'
|
||||||
|
|
||||||
|
|
||||||
|
def _build_inventory_prompt(topic: str, format_name: str, inventory_path: Path, instructions: str = "", project_content: str | None = None) -> str:
|
||||||
|
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
|
||||||
|
extra = f"\n\nZUSÄTZLICHE ANWEISUNGEN VOM NUTZER:\n{instructions}\n" if instructions else ""
|
||||||
|
|
||||||
|
return f"""Erstelle das Themeninventar für einen Lern-Guide zum Thema "{topic}" im Format "{format_name}".
|
||||||
|
|
||||||
|
{_research_line(topic, project_content)}
|
||||||
|
|
||||||
|
Das Inventar ist eine durchnummerierte Liste der Bausteine (Konzepte/Funktionen/Features), die der Guide abdecken muss — bereits in sinnvolle Teile/Sektionen gruppiert. Die ABDECKUNGS-Regel der Format-Spezifikation bestimmt die Auswahl; wie viele Punkte das sind, hängt vom Thema ab. Ein anderer Agent schreibt den Guide später strikt nach diesem Inventar — jeder Punkt wird eine Sektion.
|
||||||
|
|
||||||
|
Schreibe NUR die Inventar-Datei nach: {inventory_path}
|
||||||
|
Kein HTML, kein weasyprint.
|
||||||
|
|
||||||
|
FORMAT-SPEZIFIKATION (relevant sind ABDECKUNG und Seitenrahmen):
|
||||||
|
{spec}
|
||||||
|
{extra}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_generator_prompt(topic: str, format_name: str, html_path: Path, inventory: str, instructions: str = "", project_content: str | None = None) -> str:
|
||||||
|
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
|
||||||
|
reference = (TEMPLATES_DIR / "Referenz" / f"{format_name}.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
extra = f"\n\nZUSÄTZLICHE ANWEISUNGEN VOM NUTZER:\n{instructions}\n" if instructions else ""
|
||||||
|
|
||||||
|
return f"""Erstelle einen Lern-Guide zum Thema "{topic}" im Format "{format_name}".
|
||||||
|
|
||||||
|
{_research_line(topic, project_content)}
|
||||||
|
|
||||||
|
Schreibe die HTML-Datei nach: {html_path}
|
||||||
|
|
||||||
|
Schreibe NUR die HTML-Datei. Führe KEIN weasyprint aus, erzeuge KEINE PDF. Das übernimmt ein anderer Prozess.
|
||||||
|
|
||||||
|
THEMENINVENTAR (verbindlich — von einem Recherche-Agenten erstellt):
|
||||||
|
Jeder Inventar-Punkt muss im Guide als eigene Sektion umgesetzt werden. Nicht zusammenfassen, nicht kürzen, nichts weglassen. Der Umfang des Guides folgt aus diesem Inventar innerhalb des Seitenrahmens der Spezifikation.
|
||||||
|
{inventory}
|
||||||
|
|
||||||
|
FORMAT-SPEZIFIKATION:
|
||||||
|
{spec}
|
||||||
|
|
||||||
|
REFERENZ-IMPLEMENTIERUNG (NUR Stil-Vorlage: Bausteine, CSS, Tonalität. Umfang und Struktur kommen aus dem INVENTAR, nicht aus der Referenz):
|
||||||
|
{reference}
|
||||||
|
{extra}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rework_prompt(topic: str, format_name: str, html_path: Path, instructions: str) -> str:
|
||||||
|
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
|
||||||
|
return f"""Überarbeite die bestehende HTML-Datei {html_path} für den "{format_name}" zum Thema "{topic}".
|
||||||
|
|
||||||
|
Lies zuerst die aktuelle HTML-Datei mit dem Read-Tool.
|
||||||
|
|
||||||
|
ANWEISUNGEN VOM NUTZER:
|
||||||
|
{instructions}
|
||||||
|
|
||||||
|
FORMAT-SPEZIFIKATION (muss weiterhin eingehalten werden):
|
||||||
|
{spec}
|
||||||
|
|
||||||
|
Schreibe die überarbeitete Version in dieselbe Datei: {html_path}
|
||||||
|
Führe KEIN weasyprint aus, erzeuge KEINE PDF.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fix_prompt(topic: str, format_name: str, html_path: Path, feedback: str) -> str:
|
||||||
|
return f"""Die HTML-Datei {html_path} für den "{format_name}" zum Thema "{topic}" hat Probleme.
|
||||||
|
|
||||||
|
FEEDBACK VOM PRÜFER:
|
||||||
|
{feedback}
|
||||||
|
|
||||||
|
Behebe die Probleme in der HTML-Datei {html_path}. Schreibe die korrigierte Version in dieselbe Datei.
|
||||||
|
Führe KEIN weasyprint aus, erzeuge KEINE PDF.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_content_review_prompt(topic: str, format_name: str, html_path: Path, project_content: str | None = None) -> str:
|
||||||
|
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if project_content:
|
||||||
|
check_step = (
|
||||||
|
"SCHRITT 2 — Fakten prüfen:\n"
|
||||||
|
f'Vergleiche den Inhalt mit den folgenden PROJEKT-INHALTEN (Quelle der Wahrheit) für "{topic}". '
|
||||||
|
"Stimmen die Projekt-Fakten? Fehlt Wichtiges aus dem Projekt? "
|
||||||
|
"Externe/aktuelle Fakten (Versionsnummern fremder Tools) ergänzend per WebSearch prüfen.\n\n"
|
||||||
|
f"PROJEKT-INHALTE:\n{project_content}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
check_step = (
|
||||||
|
"SCHRITT 2 — Fakten per Websuche prüfen:\n"
|
||||||
|
f'Recherchiere mit WebSearch, ob Versionsnummern, Jahreszahlen und zentrale Fakten zu "{topic}" aktuell und korrekt sind.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""Prüfe den Inhalt der HTML-Datei {html_path} für den "{format_name}" zum Thema "{topic}".
|
||||||
|
|
||||||
|
SCHRITT 1 — HTML-Datei lesen:
|
||||||
|
Öffne die Datei {html_path} mit dem Read-Tool.
|
||||||
|
|
||||||
|
{check_step}
|
||||||
|
|
||||||
|
SCHRITT 3 — Vollständigkeit prüfen anhand dieser Spezifikation:
|
||||||
|
{spec}
|
||||||
|
|
||||||
|
Prüfkriterien:
|
||||||
|
- Sind alle Pflicht-Kapitel/Sektionen vorhanden?
|
||||||
|
- Stimmen Versionsnummern und Fakten?
|
||||||
|
- Ist der Inhalt fachlich korrekt und aktuell?
|
||||||
|
- Entspricht der Schwierigkeitsgrad dem Format?
|
||||||
|
- Sind Pflicht-Elemente vorhanden (Cover, TOC, Recall-Boxen, Callouts, Code-Beispiele)?
|
||||||
|
|
||||||
|
SCHRITT 4 — Antworte mit GENAU EINEM der folgenden Formate:
|
||||||
|
|
||||||
|
Bei Bestehen:
|
||||||
|
PASS
|
||||||
|
|
||||||
|
Bei Nicht-Bestehen:
|
||||||
|
FAIL
|
||||||
|
- Problem 1
|
||||||
|
- Problem 2
|
||||||
|
- ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", reindex: bool = False, provider: str = DEFAULT_PROVIDER) -> None:
|
||||||
|
async with _semaphore:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now)
|
||||||
|
|
||||||
|
html_path, pdf_path = final_paths(topic, format_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_step = "Generierung"
|
||||||
|
current_timeout = AGENT_TIMEOUT
|
||||||
|
|
||||||
|
# Step 0: Projekt einlesen (nur wenn topic ein Projekt ist)
|
||||||
|
project_content: str | None = None
|
||||||
|
if project_dir(topic).is_dir():
|
||||||
|
cache_path = project_cache_path(topic)
|
||||||
|
if reindex or not cache_path.exists():
|
||||||
|
await _set_progress(guide_id, "Lese Projekt…")
|
||||||
|
current_step = "Projekt-Einlesen"
|
||||||
|
index_prompt = _build_project_index_prompt(topic, cache_path, cache_path.exists())
|
||||||
|
returncode, idx_out, idx_err = await run_agent(
|
||||||
|
guide_id, index_prompt, AGENT_TIMEOUT,
|
||||||
|
provider=provider, role="fast", capabilities="files",
|
||||||
|
)
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
if returncode != 0:
|
||||||
|
await _fail(guide_id, _claude_error("Projekt-Einlese-Fehler", returncode, idx_out, idx_err))
|
||||||
|
return
|
||||||
|
if not cache_path.exists():
|
||||||
|
await _fail(guide_id, "Projekt-Wissensdatei wurde nicht erstellt")
|
||||||
|
return
|
||||||
|
project_content = cache_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Step 1: Inventar-Agent recherchiert die Bausteine des Themas
|
||||||
|
inventory_path = html_path.with_suffix(".inventar.md")
|
||||||
|
await _set_progress(guide_id, "Erstelle Themeninventar…")
|
||||||
|
current_step = "Inventar"
|
||||||
|
inv_prompt = _build_inventory_prompt(topic, format_name, inventory_path, instructions, project_content)
|
||||||
|
returncode, inv_out, inv_err = await run_agent(guide_id, inv_prompt, AGENT_TIMEOUT, provider=provider, role="fast", capabilities="full")
|
||||||
|
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
if returncode != 0:
|
||||||
|
await _fail(guide_id, _claude_error("Inventar-Fehler", returncode, inv_out, inv_err))
|
||||||
|
return
|
||||||
|
if not inventory_path.exists():
|
||||||
|
await _fail(guide_id, "Themeninventar wurde nicht erstellt")
|
||||||
|
return
|
||||||
|
inventory = inventory_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Step 2: Generator-Agent erstellt HTML nach Inventar
|
||||||
|
await _set_progress(guide_id, "Generiere HTML…")
|
||||||
|
current_step = "Generierung"
|
||||||
|
gen_prompt = _build_generator_prompt(topic, format_name, html_path, inventory, instructions, project_content)
|
||||||
|
returncode, stdout, stderr = await run_agent(guide_id, gen_prompt, AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full")
|
||||||
|
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
if returncode != 0:
|
||||||
|
await _fail(guide_id, _claude_error("Generator-Fehler", returncode, stdout, stderr))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not html_path.exists():
|
||||||
|
await _fail(guide_id, "HTML-Datei wurde nicht erstellt")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 2: Inhalts-Review (1x, kein Loop)
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
await _set_progress(guide_id, "Prüfe Inhalt…")
|
||||||
|
current_step = "Inhalts-Review"
|
||||||
|
current_timeout = AGENT_TIMEOUT
|
||||||
|
content_prompt = _build_content_review_prompt(topic, format_name, html_path, project_content)
|
||||||
|
returncode, review_out, review_err = await run_agent(guide_id, content_prompt, AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full")
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
await _fail(guide_id, _claude_error("Inhalts-Review-Fehler", returncode, review_out, review_err))
|
||||||
|
return
|
||||||
|
|
||||||
|
review_text = review_out.strip()
|
||||||
|
if not review_text.startswith("PASS"):
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
feedback = review_text.replace("FAIL", "").strip()
|
||||||
|
await _set_progress(guide_id, "Korrigiere Inhalt…")
|
||||||
|
current_step = "Inhalts-Korrektur"
|
||||||
|
current_timeout = AGENT_TIMEOUT
|
||||||
|
fix_prompt = _build_fix_prompt(topic, format_name, html_path, feedback)
|
||||||
|
returncode, fix_out, fix_err = await run_agent(guide_id, fix_prompt, AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full")
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
await _fail(guide_id, _claude_error("Fix-Fehler", returncode, fix_out, fix_err))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 3: PDF rendern
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
await _set_progress(guide_id, "Rendere PDF…")
|
||||||
|
ok, err = await _render_pdf(html_path, pdf_path)
|
||||||
|
if not ok:
|
||||||
|
await _fail(guide_id, f"WeasyPrint-Fehler: {err}")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_guide(
|
||||||
|
guide_id, status="done", progress=None, updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await _fail(guide_id, f"Timeout bei {current_step} nach {current_timeout}s")
|
||||||
|
except Exception as e:
|
||||||
|
await _fail(guide_id, str(e)[:2000])
|
||||||
|
finally:
|
||||||
|
_cancelled.discard(guide_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def rework_guide(guide_id: str, topic: str, format_name: str, instructions: str, provider: str = DEFAULT_PROVIDER) -> None:
|
||||||
|
async with _semaphore:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_guide(guide_id, status="generating", progress="Überarbeite…", updated_at=now)
|
||||||
|
|
||||||
|
final_html, final_pdf = final_paths(topic, format_name)
|
||||||
|
tmp_html, tmp_pdf = temp_paths(guide_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not final_html.exists():
|
||||||
|
await _fail(guide_id, "Original-HTML nicht gefunden")
|
||||||
|
return
|
||||||
|
|
||||||
|
shutil.copy2(final_html, tmp_html)
|
||||||
|
|
||||||
|
current_step = "Überarbeitung"
|
||||||
|
current_timeout = AGENT_TIMEOUT
|
||||||
|
|
||||||
|
rework_prompt = _build_rework_prompt(topic, format_name, tmp_html, instructions)
|
||||||
|
returncode, stdout, stderr = await run_agent(guide_id, rework_prompt, AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full")
|
||||||
|
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
if returncode != 0:
|
||||||
|
await _fail(guide_id, _claude_error("Rework-Fehler", returncode, stdout, stderr))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not tmp_html.exists():
|
||||||
|
await _fail(guide_id, "HTML-Datei wurde nicht erstellt")
|
||||||
|
return
|
||||||
|
|
||||||
|
await _set_progress(guide_id, "Rendere PDF…")
|
||||||
|
ok, err = await _render_pdf(tmp_html, tmp_pdf)
|
||||||
|
if not ok:
|
||||||
|
await _fail(guide_id, f"WeasyPrint-Fehler: {err}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Atomar: Temp → Final umbenennen
|
||||||
|
tmp_html.replace(final_html)
|
||||||
|
tmp_pdf.replace(final_pdf)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_guide(
|
||||||
|
guide_id, status="done", progress=None, updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await _fail(guide_id, f"Timeout bei {current_step} nach {current_timeout}s")
|
||||||
|
except Exception as e:
|
||||||
|
await _fail(guide_id, str(e)[:2000])
|
||||||
|
finally:
|
||||||
|
_cancelled.discard(guide_id)
|
||||||
|
tmp_html.unlink(missing_ok=True)
|
||||||
|
tmp_pdf.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_error(label: str, returncode: int, stdout: str, stderr: str) -> str:
|
||||||
|
stderr = (stderr or "").strip()
|
||||||
|
if stderr:
|
||||||
|
return f"{label}: {stderr[:1000]}"
|
||||||
|
tail = (stdout or "").strip()[-500:]
|
||||||
|
if tail:
|
||||||
|
return f"{label} (exit {returncode}, stderr leer): …{tail}"
|
||||||
|
return f"{label} (exit {returncode}, ohne Ausgabe)"
|
||||||
|
|
||||||
|
|
||||||
|
async def _fail(guide_id: str, msg: str) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bausteine ---
|
||||||
|
|
||||||
|
_suggestions_generating: set[str] = set()
|
||||||
|
_sorting: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def is_suggestions_generating(topic: str) -> bool:
|
||||||
|
return topic in _suggestions_generating
|
||||||
|
|
||||||
|
|
||||||
|
def is_sorting(topic: str) -> bool:
|
||||||
|
return topic in _sorting
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(text: str):
|
||||||
|
text = text.strip()
|
||||||
|
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||||
|
text = re.sub(r"\s*```$", "", text)
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_suggestions_prompt(topic: str, html_paths: list[Path], existing_titles: list[str]) -> str:
|
||||||
|
spec = (TEMPLATES_DIR / "Format" / "Baustein.md").read_text(encoding="utf-8")
|
||||||
|
reference = (TEMPLATES_DIR / "Referenz" / "Baustein.md").read_text(encoding="utf-8")
|
||||||
|
existing_list = "\n".join(f"- {t}" for t in existing_titles) if existing_titles else "(keine)"
|
||||||
|
|
||||||
|
if html_paths:
|
||||||
|
read_instructions = "\n".join(f"- Lies: {p}" for p in html_paths)
|
||||||
|
guides_section = f"""SCHRITT 1 — Guides lesen:
|
||||||
|
{read_instructions}
|
||||||
|
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
guides_section = ""
|
||||||
|
|
||||||
|
return f"""Schlage fundamentale Bausteine (Kernkonzepte) zum Thema "{topic}" vor.
|
||||||
|
|
||||||
|
{guides_section}Bereits vorhandene Bausteine (NICHT erneut vorschlagen):
|
||||||
|
{existing_list}
|
||||||
|
|
||||||
|
FORMAT-SPEZIFIKATION:
|
||||||
|
{spec}
|
||||||
|
|
||||||
|
REFERENZ-BEISPIEL:
|
||||||
|
{reference}
|
||||||
|
|
||||||
|
Schlage 40 Bausteine vor. Antworte AUSSCHLIESSLICH mit einem JSON-Array. Jedes Element hat:
|
||||||
|
- "title"
|
||||||
|
- "description"
|
||||||
|
- "purpose"
|
||||||
|
- "examples": Array mit 1 Objekt {{"label": "...", "code": "..."}}
|
||||||
|
|
||||||
|
Orientiere dich an der Spezifikation und Referenz. NUR das JSON-Array, kein weiterer Text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_baustein_detail_prompt(topic: str, title: str, instructions: str = "") -> str:
|
||||||
|
spec = (TEMPLATES_DIR / "Format" / "Baustein.md").read_text(encoding="utf-8")
|
||||||
|
reference = (TEMPLATES_DIR / "Referenz" / "Baustein.md").read_text(encoding="utf-8")
|
||||||
|
extra = f"\n\nZUSÄTZLICHE INFOS VOM NUTZER:\n{instructions}\n" if instructions else ""
|
||||||
|
|
||||||
|
return f"""Generiere Details für den Baustein "{title}" im Kontext des Themas "{topic}".
|
||||||
|
|
||||||
|
FORMAT-SPEZIFIKATION:
|
||||||
|
{spec}
|
||||||
|
|
||||||
|
REFERENZ-BEISPIEL:
|
||||||
|
{reference}
|
||||||
|
{extra}
|
||||||
|
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt mit den Feldern "description", "purpose", "examples".
|
||||||
|
"examples" ist ein Array mit 1 Objekt {{"label": "...", "code": "..."}}.
|
||||||
|
Orientiere dich an der Spezifikation und Referenz. Kein weiterer Text, nur das JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_suggestions(topic: str, html_paths: list[Path], provider: str = DEFAULT_PROVIDER) -> None:
|
||||||
|
_suggestions_generating.add(topic)
|
||||||
|
try:
|
||||||
|
existing = await list_bausteine(topic)
|
||||||
|
existing_titles = [b["title"] for b in existing]
|
||||||
|
|
||||||
|
await delete_pending_suggestions(topic)
|
||||||
|
|
||||||
|
prompt = _build_suggestions_prompt(topic, html_paths, existing_titles)
|
||||||
|
capabilities = "read" if html_paths else "none"
|
||||||
|
returncode, stdout, stderr = await run_agent("suggestions-" + topic, prompt, 1800, provider=provider, role="fast", capabilities=capabilities)
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
items = _parse_json(stdout)
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
suggestions = []
|
||||||
|
for item in items[:40]:
|
||||||
|
suggestions.append({
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"topic": topic,
|
||||||
|
"title": item.get("title", ""),
|
||||||
|
"description": item.get("description", ""),
|
||||||
|
"purpose": item.get("purpose", ""),
|
||||||
|
"example": json.dumps(item.get("examples", []), ensure_ascii=False),
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": now,
|
||||||
|
})
|
||||||
|
if suggestions:
|
||||||
|
await create_suggestions(suggestions)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_suggestions_generating.discard(topic)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_baustein_detail(baustein_id: str, topic: str, title: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
||||||
|
try:
|
||||||
|
prompt = _build_baustein_detail_prompt(topic, title, instructions)
|
||||||
|
returncode, stdout, stderr = await run_agent("baustein-" + baustein_id, prompt, 180, provider=provider, role="fast", capabilities="none")
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
data = _parse_json(stdout)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_baustein(
|
||||||
|
baustein_id,
|
||||||
|
description=data.get("description", ""),
|
||||||
|
purpose=data.get("purpose", ""),
|
||||||
|
example=json.dumps(data.get("examples", []), ensure_ascii=False),
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def rework_baustein(baustein_id: str, topic: str, title: str, current: dict, instructions: str, provider: str = DEFAULT_PROVIDER) -> None:
|
||||||
|
try:
|
||||||
|
prompt = _build_baustein_rework_prompt(topic, title, current, instructions)
|
||||||
|
returncode, stdout, stderr = await run_agent("baustein-" + baustein_id, prompt, 180, provider=provider, role="fast", capabilities="none")
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
data = _parse_json(stdout)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_baustein(
|
||||||
|
baustein_id,
|
||||||
|
title=data.get("title", title),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
purpose=data.get("purpose", ""),
|
||||||
|
example=json.dumps(data.get("examples", []), ensure_ascii=False),
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _build_baustein_rework_prompt(topic: str, title: str, current: dict, instructions: str) -> str:
|
||||||
|
spec = (TEMPLATES_DIR / "Format" / "Baustein.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
current_json = json.dumps({
|
||||||
|
"title": title,
|
||||||
|
"description": current.get("description", ""),
|
||||||
|
"purpose": current.get("purpose", ""),
|
||||||
|
"examples": current.get("examples", []),
|
||||||
|
}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return f"""Überarbeite den Baustein "{title}" zum Thema "{topic}" gemäß den Anweisungen.
|
||||||
|
|
||||||
|
AKTUELLER STAND:
|
||||||
|
{current_json}
|
||||||
|
|
||||||
|
ANWEISUNGEN VOM NUTZER:
|
||||||
|
{instructions}
|
||||||
|
|
||||||
|
FORMAT-SPEZIFIKATION:
|
||||||
|
{spec}
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt mit den Feldern "title", "description", "purpose", "examples".
|
||||||
|
"examples" ist ein Array mit Objekten {{"label": "...", "code": "..."}}.
|
||||||
|
Orientiere dich an der Spezifikation. Kein weiterer Text, nur das JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sort_prompt(topic: str, bausteine: list[dict], instructions: str) -> str:
|
||||||
|
items = "\n".join(
|
||||||
|
f"- id={b['id']} | {b['title']} | {b['description']} | {b['purpose']}"
|
||||||
|
for b in bausteine
|
||||||
|
)
|
||||||
|
if instructions:
|
||||||
|
criterion = f"Sortiere die folgenden Bausteine zum Thema \"{topic}\" STRIKT nach diesem Kriterium:\n\n{instructions}"
|
||||||
|
else:
|
||||||
|
criterion = f"Sortiere die folgenden Bausteine zum Thema \"{topic}\" von Anfaenger zu Experte (erstes = einfachster, letztes = komplexester)."
|
||||||
|
|
||||||
|
return f"""{criterion}
|
||||||
|
|
||||||
|
BAUSTEINE:
|
||||||
|
{items}
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH mit einem JSON-Array der IDs in der gewuenschten Reihenfolge.
|
||||||
|
Beispiel: [\"id1\", \"id2\", \"id3\"]
|
||||||
|
|
||||||
|
Kein weiterer Text, nur das JSON-Array.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_topic_suggest_prompt(problem: str, existing_topics: list[str]) -> str:
|
||||||
|
template = (TEMPLATES_DIR / "Format" / "Suche.md").read_text(encoding="utf-8")
|
||||||
|
existing = "\n".join(f"- {t}" for t in existing_topics) if existing_topics else "(keine)"
|
||||||
|
return template.replace("{problem}", problem).replace("{existing}", existing)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
|
||||||
|
transcript = "\n".join(
|
||||||
|
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
|
||||||
|
for m in messages
|
||||||
|
)
|
||||||
|
outline_block = outline.strip() or "(keine)"
|
||||||
|
section_block = section.strip() or "(kein Abschnitt erkannt)"
|
||||||
|
return f"""Du bist ein hilfreicher Tutor zum Lern-Guide "{topic}" (Format: {format_name}). Ein Leser stellt dir Fragen, während er den Guide liest.
|
||||||
|
|
||||||
|
GLIEDERUNG DES GUIDES:
|
||||||
|
{outline_block}
|
||||||
|
|
||||||
|
AKTUELLER ABSCHNITT, DEN DER LESER GERADE LIEST:
|
||||||
|
{section_block}
|
||||||
|
|
||||||
|
BISHERIGER CHAT-VERLAUF:
|
||||||
|
{transcript}
|
||||||
|
|
||||||
|
Antworte als Assistent auf die letzte Nutzer-Nachricht.
|
||||||
|
|
||||||
|
WICHTIG – Antwortstil:
|
||||||
|
- KURZ und EINFACH: 1–3 Sätze, klare Sprache.
|
||||||
|
- Keine Einleitung, keine Wiederholung der Frage, kein Markdown-Drumherum.
|
||||||
|
- Beantworte nur die Frage; nutze den Abschnitt und die Gliederung als Kontext.
|
||||||
|
|
||||||
|
Gib NUR die Antwort aus, kein Präfix wie "Assistent:"."""
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
|
||||||
|
try:
|
||||||
|
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
|
||||||
|
returncode, stdout, stderr = await run_agent(
|
||||||
|
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||||
|
)
|
||||||
|
if returncode != 0:
|
||||||
|
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||||
|
reply = stdout.strip()
|
||||||
|
return reply or "Entschuldigung, ich habe keine Antwort erhalten."
|
||||||
|
except Exception:
|
||||||
|
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||||
|
|
||||||
|
|
||||||
|
async def suggest_topics(problem: str, existing_topics: list[str] | None = None, provider: str = DEFAULT_PROVIDER) -> list[dict]:
|
||||||
|
try:
|
||||||
|
prompt = _build_topic_suggest_prompt(problem, existing_topics or [])
|
||||||
|
returncode, stdout, stderr = await run_agent(
|
||||||
|
"topic-suggest-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||||
|
)
|
||||||
|
if returncode != 0:
|
||||||
|
return []
|
||||||
|
items = _parse_json(stdout)
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
title = str(item.get("title", "")).strip()[:100]
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
result.append({"title": title, "reason": str(item.get("reason", "")).strip()})
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def sort_bausteine(topic: str, bausteine: list[dict], instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
||||||
|
_sorting.add(topic)
|
||||||
|
try:
|
||||||
|
prompt = _build_sort_prompt(topic, bausteine, instructions)
|
||||||
|
returncode, stdout, stderr = await run_agent("sort-" + topic, prompt, 600, provider=provider, role="fast", capabilities="none")
|
||||||
|
if returncode != 0:
|
||||||
|
return
|
||||||
|
ids = _parse_json(stdout)
|
||||||
|
if not isinstance(ids, list):
|
||||||
|
return
|
||||||
|
order_map = {bid: i for i, bid in enumerate(ids) if isinstance(bid, str)}
|
||||||
|
if order_map:
|
||||||
|
await update_baustein_sort_orders(topic, order_map)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[sort] topic={topic} Exception: {type(e).__name__}: {e}")
|
||||||
|
finally:
|
||||||
|
_sorting.discard(topic)
|
||||||
26
backend/main.py
Normal file
26
backend/main.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from config import FRONTEND_DIST, STORAGE_DIR, PROJECTS_CACHE_DIR
|
||||||
|
from database import init_db, close_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)
|
||||||
|
PROJECTS_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Guides Generator", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
if FRONTEND_DIST.exists():
|
||||||
|
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
|
||||||
121
backend/models.py
Normal file
121
backend/models.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
FormatType = Literal[
|
||||||
|
"OnePager",
|
||||||
|
"Cheatsheet",
|
||||||
|
"MiniGuide",
|
||||||
|
"Guide",
|
||||||
|
"EndGuide",
|
||||||
|
]
|
||||||
|
|
||||||
|
ProviderType = Literal["claude", "minimax"]
|
||||||
|
|
||||||
|
|
||||||
|
class GuideCreateRequest(BaseModel):
|
||||||
|
topic: str = Field(min_length=1, max_length=100)
|
||||||
|
format: FormatType
|
||||||
|
instructions: str = Field(default="", max_length=2000)
|
||||||
|
reindex: bool = False
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
cached: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
available: bool
|
||||||
|
|
||||||
|
|
||||||
|
class GuideReworkRequest(BaseModel):
|
||||||
|
instructions: str = Field(min_length=1, max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class GuideResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
topic: str
|
||||||
|
format: str
|
||||||
|
status: str
|
||||||
|
progress: str | None = None
|
||||||
|
error_msg: str | None = None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class BausteinCreateRequest(BaseModel):
|
||||||
|
topic: str = Field(min_length=1, max_length=100)
|
||||||
|
title: str = Field(min_length=1, max_length=200)
|
||||||
|
instructions: str = Field(default="", max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class BausteinReworkRequest(BaseModel):
|
||||||
|
instructions: str = Field(min_length=1, max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class BausteinResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
topic: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
purpose: str
|
||||||
|
example: str
|
||||||
|
sort_order: int = 0
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class BausteinSortRequest(BaseModel):
|
||||||
|
instructions: str = Field(default="", max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
topic: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
purpose: str
|
||||||
|
example: str
|
||||||
|
status: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class TopicSuggestRequest(BaseModel):
|
||||||
|
problem: str = Field(min_length=1, max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class TopicSuggestion(BaseModel):
|
||||||
|
title: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
role: Literal["user", "assistant"]
|
||||||
|
content: str = Field(min_length=1, max_length=8000)
|
||||||
|
|
||||||
|
|
||||||
|
class GuideChatRequest(BaseModel):
|
||||||
|
section: str = Field(default="", max_length=20000)
|
||||||
|
outline: str = Field(default="", max_length=8000)
|
||||||
|
messages: list[ChatMessage] = Field(min_length=1)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class GuideChatResponse(BaseModel):
|
||||||
|
reply: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressUpdate(BaseModel):
|
||||||
|
chapter: str = Field(min_length=1, max_length=100)
|
||||||
|
done: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressResponse(BaseModel):
|
||||||
|
chapters: list[str]
|
||||||
25
backend/paths.py
Normal file
25
backend/paths.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import STORAGE_DIR, PROJECTS_DIR, PROJECTS_CACHE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def safe_basename(topic: str, format_name: str) -> str:
|
||||||
|
clean = topic.replace("/", "_").replace("\x00", "")
|
||||||
|
return f"{clean} - {format_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def final_paths(topic: str, format_name: str) -> tuple[Path, Path]:
|
||||||
|
base = safe_basename(topic, format_name)
|
||||||
|
return STORAGE_DIR / "html" / f"{base}.html", STORAGE_DIR / "pdf" / f"{base}.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def temp_paths(guide_id: str) -> tuple[Path, Path]:
|
||||||
|
return STORAGE_DIR / "html" / f"{guide_id}.tmp.html", STORAGE_DIR / "pdf" / f"{guide_id}.tmp.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def project_dir(name: str) -> Path:
|
||||||
|
return PROJECTS_DIR / name
|
||||||
|
|
||||||
|
|
||||||
|
def project_cache_path(name: str) -> Path:
|
||||||
|
return PROJECTS_CACHE_DIR / f"{name}.md"
|
||||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
aiosqlite
|
||||||
|
weasyprint
|
||||||
|
pdf2image
|
||||||
320
backend/routes.py
Normal file
320
backend/routes.py
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from agents import provider_available
|
||||||
|
from config import FORMAT_META, PROJECTS_DIR, PROVIDERS
|
||||||
|
from database import (
|
||||||
|
create_guide, delete_guide, get_guide, list_guides,
|
||||||
|
create_baustein as db_create_baustein, list_bausteine, get_baustein, delete_baustein as db_delete_baustein,
|
||||||
|
list_suggestions, get_suggestion, update_suggestion, delete_suggestion,
|
||||||
|
list_progress, set_progress, delete_progress,
|
||||||
|
)
|
||||||
|
from generator import generate_guide, rework_guide, cancel_guide, generate_suggestions, generate_baustein_detail, rework_baustein, sort_bausteine, suggest_topics, chat_with_guide, is_suggestions_generating, is_sorting
|
||||||
|
from models import (
|
||||||
|
GuideCreateRequest, GuideReworkRequest, GuideResponse,
|
||||||
|
BausteinCreateRequest, BausteinReworkRequest, BausteinSortRequest, BausteinResponse, SuggestionResponse,
|
||||||
|
TopicSuggestRequest, TopicSuggestion,
|
||||||
|
GuideChatRequest, GuideChatResponse,
|
||||||
|
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||||
|
)
|
||||||
|
from paths import final_paths, project_dir, project_cache_path
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/formats")
|
||||||
|
async def get_formats():
|
||||||
|
return FORMAT_META
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers", response_model=list[ProviderInfo])
|
||||||
|
async def get_providers():
|
||||||
|
return [{"id": pid, "available": provider_available(pid)} for pid in PROVIDERS]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_project_name(name: str) -> str:
|
||||||
|
if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name:
|
||||||
|
raise HTTPException(400, "Ungültiger Projektname")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects", response_model=list[ProjectResponse])
|
||||||
|
async def list_projects():
|
||||||
|
if not PROJECTS_DIR.is_dir():
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for entry in sorted(PROJECTS_DIR.iterdir()):
|
||||||
|
if entry.is_dir():
|
||||||
|
result.append({"name": entry.name, "cached": project_cache_path(entry.name).exists()})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projects/{name}")
|
||||||
|
async def remove_project(name: str):
|
||||||
|
_safe_project_name(name)
|
||||||
|
pdir = project_dir(name)
|
||||||
|
if not pdir.is_dir():
|
||||||
|
raise HTTPException(404, "Projekt nicht gefunden")
|
||||||
|
shutil.rmtree(pdir)
|
||||||
|
project_cache_path(name).unlink(missing_ok=True)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/topic-suggestions", response_model=list[TopicSuggestion])
|
||||||
|
async def topic_suggestions(req: TopicSuggestRequest):
|
||||||
|
guides = await list_guides()
|
||||||
|
existing_topics = sorted({g["topic"] for g in guides})
|
||||||
|
return await suggest_topics(req.problem.strip(), existing_topics, provider=req.provider)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
"instructions": req.instructions.strip(),
|
||||||
|
"status": "queued",
|
||||||
|
"progress": None,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
await create_guide(guide)
|
||||||
|
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.reindex, req.provider))
|
||||||
|
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":
|
||||||
|
raise HTTPException(404, "HTML nicht verfügbar")
|
||||||
|
html_path, _ = final_paths(guide["topic"], guide["format"])
|
||||||
|
if not html_path.exists():
|
||||||
|
raise HTTPException(404, "Datei nicht gefunden")
|
||||||
|
return FileResponse(html_path, media_type="text/html", content_disposition_type="inline")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/guides/{guide_id}/rework")
|
||||||
|
async def rework(guide_id: str, req: GuideReworkRequest):
|
||||||
|
guide = await get_guide(guide_id)
|
||||||
|
if guide is None:
|
||||||
|
raise HTTPException(404, "Guide nicht gefunden")
|
||||||
|
if guide["status"] != "done":
|
||||||
|
raise HTTPException(400, "Guide muss fertig sein")
|
||||||
|
asyncio.create_task(rework_guide(guide_id, guide["topic"], guide["format"], req.instructions.strip(), req.provider))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse)
|
||||||
|
async def guide_chat(guide_id: str, req: GuideChatRequest):
|
||||||
|
guide = await get_guide(guide_id)
|
||||||
|
if guide is None:
|
||||||
|
raise HTTPException(404, "Guide nicht gefunden")
|
||||||
|
reply = await chat_with_guide(
|
||||||
|
guide["topic"], guide["format"], req.section, req.outline,
|
||||||
|
[m.model_dump() for m in req.messages],
|
||||||
|
provider=req.provider,
|
||||||
|
)
|
||||||
|
return {"reply": reply}
|
||||||
|
|
||||||
|
|
||||||
|
@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":
|
||||||
|
raise HTTPException(404, "PDF nicht verfügbar")
|
||||||
|
_, pdf_path = final_paths(guide["topic"], guide["format"])
|
||||||
|
if not pdf_path.exists():
|
||||||
|
raise HTTPException(404, "Datei nicht gefunden")
|
||||||
|
return FileResponse(pdf_path, filename=pdf_path.name, 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")
|
||||||
|
html_path, pdf_path = final_paths(guide["topic"], guide["format"])
|
||||||
|
html_path.unlink(missing_ok=True)
|
||||||
|
html_path.with_suffix(".inventar.md").unlink(missing_ok=True)
|
||||||
|
pdf_path.unlink(missing_ok=True)
|
||||||
|
await delete_progress(guide_id)
|
||||||
|
await delete_guide(guide_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/guides/{guide_id}/progress", response_model=ProgressResponse)
|
||||||
|
async def get_progress(guide_id: str):
|
||||||
|
guide = await get_guide(guide_id)
|
||||||
|
if guide is None:
|
||||||
|
raise HTTPException(404, "Guide nicht gefunden")
|
||||||
|
return {"chapters": await list_progress(guide_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/guides/{guide_id}/progress", response_model=ProgressResponse)
|
||||||
|
async def update_progress(guide_id: str, req: ProgressUpdate):
|
||||||
|
guide = await get_guide(guide_id)
|
||||||
|
if guide is None:
|
||||||
|
raise HTTPException(404, "Guide nicht gefunden")
|
||||||
|
await set_progress(guide_id, req.chapter, req.done)
|
||||||
|
return {"chapters": await list_progress(guide_id)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bausteine ---
|
||||||
|
|
||||||
|
@router.get("/bausteine", response_model=list[BausteinResponse])
|
||||||
|
async def get_bausteine(topic: str):
|
||||||
|
return await list_bausteine(topic)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bausteine", response_model=BausteinResponse)
|
||||||
|
async def add_baustein(req: BausteinCreateRequest):
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
baustein = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"topic": req.topic.strip(),
|
||||||
|
"title": req.title.strip(),
|
||||||
|
"description": "",
|
||||||
|
"purpose": "",
|
||||||
|
"example": "",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
await db_create_baustein(baustein)
|
||||||
|
asyncio.create_task(generate_baustein_detail(baustein["id"], baustein["topic"], baustein["title"], req.instructions.strip(), req.provider))
|
||||||
|
return baustein
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/bausteine/{baustein_id}")
|
||||||
|
async def remove_baustein(baustein_id: str):
|
||||||
|
b = await get_baustein(baustein_id)
|
||||||
|
if b is None:
|
||||||
|
raise HTTPException(404, "Baustein nicht gefunden")
|
||||||
|
await db_delete_baustein(baustein_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bausteine/{baustein_id}/rework")
|
||||||
|
async def rework_baustein_route(baustein_id: str, req: BausteinReworkRequest):
|
||||||
|
b = await get_baustein(baustein_id)
|
||||||
|
if b is None:
|
||||||
|
raise HTTPException(404, "Baustein nicht gefunden")
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
examples = json.loads(b.get("example") or "[]")
|
||||||
|
except Exception:
|
||||||
|
examples = []
|
||||||
|
current = {
|
||||||
|
"description": b.get("description", ""),
|
||||||
|
"purpose": b.get("purpose", ""),
|
||||||
|
"examples": examples,
|
||||||
|
}
|
||||||
|
asyncio.create_task(rework_baustein(baustein_id, b["topic"], b["title"], current, req.instructions.strip(), req.provider))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bausteine/sort")
|
||||||
|
async def sort_bausteine_route(topic: str, req: BausteinSortRequest):
|
||||||
|
if is_sorting(topic):
|
||||||
|
return {"ok": True, "status": "already_sorting"}
|
||||||
|
bausteine = await list_bausteine(topic)
|
||||||
|
if not bausteine:
|
||||||
|
return {"ok": True}
|
||||||
|
asyncio.create_task(sort_bausteine(topic, bausteine, req.instructions.strip(), req.provider))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bausteine/sort/status")
|
||||||
|
async def sort_status(topic: str):
|
||||||
|
return {"sorting": is_sorting(topic)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Baustein Suggestions ---
|
||||||
|
|
||||||
|
@router.get("/bausteine/suggestions", response_model=list[SuggestionResponse])
|
||||||
|
async def get_suggestions(topic: str):
|
||||||
|
return await list_suggestions(topic)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bausteine/suggestions/generate")
|
||||||
|
async def trigger_suggestions(topic: str, provider: str = "claude"):
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
raise HTTPException(400, "Unbekannter Provider")
|
||||||
|
if is_suggestions_generating(topic):
|
||||||
|
return {"ok": True, "status": "already_generating"}
|
||||||
|
guides = await list_guides()
|
||||||
|
html_paths = []
|
||||||
|
for g in guides:
|
||||||
|
if g["topic"] == topic and g["status"] == "done":
|
||||||
|
html_path, _ = final_paths(g["topic"], g["format"])
|
||||||
|
if html_path.exists():
|
||||||
|
html_paths.append(html_path)
|
||||||
|
asyncio.create_task(generate_suggestions(topic, html_paths, provider))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bausteine/suggestions/status")
|
||||||
|
async def suggestions_status(topic: str):
|
||||||
|
return {"generating": is_suggestions_generating(topic)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bausteine/suggestions/{suggestion_id}/add")
|
||||||
|
async def accept_suggestion(suggestion_id: str):
|
||||||
|
s = await get_suggestion(suggestion_id)
|
||||||
|
if s is None:
|
||||||
|
raise HTTPException(404, "Vorschlag nicht gefunden")
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
baustein = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"topic": s["topic"],
|
||||||
|
"title": s["title"],
|
||||||
|
"description": s["description"],
|
||||||
|
"purpose": s["purpose"],
|
||||||
|
"example": s["example"],
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
await db_create_baustein(baustein)
|
||||||
|
await delete_suggestion(suggestion_id)
|
||||||
|
return baustein
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bausteine/suggestions/{suggestion_id}/ignore")
|
||||||
|
async def ignore_suggestion(suggestion_id: str):
|
||||||
|
s = await get_suggestion(suggestion_id)
|
||||||
|
if s is None:
|
||||||
|
raise HTTPException(404, "Vorschlag nicht gefunden")
|
||||||
|
await update_suggestion(suggestion_id, status="ignored")
|
||||||
|
return {"ok": True}
|
||||||
77
dev-ops/opencode.json
Normal file
77
dev-ops/opencode.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"minimax": {
|
||||||
|
"options": {
|
||||||
|
"apiKey": "{env:MINIMAX_API_KEY}"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"MiniMax-M3": {
|
||||||
|
"name": "MiniMax M3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"minimax-search": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["uvx", "minimax-coding-plan-mcp"],
|
||||||
|
"environment": {
|
||||||
|
"MINIMAX_API_KEY": "{env:MINIMAX_API_KEY}",
|
||||||
|
"MINIMAX_API_HOST": "https://api.minimax.io"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"full": {
|
||||||
|
"description": "Alle Tools: Dateien, Bash, Websuche",
|
||||||
|
"permission": {
|
||||||
|
"edit": "allow",
|
||||||
|
"bash": "allow",
|
||||||
|
"webfetch": "allow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"description": "Dateien lesen/schreiben + Bash, keine Websuche",
|
||||||
|
"permission": {
|
||||||
|
"edit": "allow",
|
||||||
|
"bash": "allow",
|
||||||
|
"webfetch": "deny"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"minimax-search*": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"readonly": {
|
||||||
|
"description": "Nur Dateien lesen",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny",
|
||||||
|
"webfetch": "deny"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"write": false,
|
||||||
|
"edit": false,
|
||||||
|
"bash": false,
|
||||||
|
"minimax-search*": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"description": "Reine Textantwort, keine Tools",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny",
|
||||||
|
"webfetch": "deny"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"write": false,
|
||||||
|
"edit": false,
|
||||||
|
"bash": false,
|
||||||
|
"read": false,
|
||||||
|
"glob": false,
|
||||||
|
"grep": false,
|
||||||
|
"minimax-search*": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
guides:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
container_name: guides
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
|
||||||
|
- MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
volumes:
|
||||||
|
- ./storage:/app/storage
|
||||||
|
- ./projects:/app/projects
|
||||||
|
- ./.claude-data:/home/app/.claude
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.guidesapp.rule=Host(`guides.marha.de`)"
|
||||||
|
- "traefik.http.routers.guidesapp.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.guidesapp.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.guidesapp.loadbalancer.server.port=8000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
external: 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
|
||||||
|
```
|
||||||
22
frontend/index.html
Normal file
22
frontend/index.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!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>Guides</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var stored = localStorage.getItem('darkMode')
|
||||||
|
var dark = stored === null
|
||||||
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
: stored === 'true'
|
||||||
|
if (dark) document.documentElement.classList.add('dark')
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</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"]
|
||||||
|
}
|
||||||
2282
frontend/package-lock.json
generated
Normal file
2282
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "^3.4.7",
|
||||||
|
"marked": "^18.0.4",
|
||||||
|
"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 |
459
frontend/src/App.vue
Normal file
459
frontend/src/App.vue
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, reworkGuide as apiRework, createBaustein as apiCreateBaustein, fetchProjects, deleteProject as apiDeleteProject, fetchProviders } from './api.js'
|
||||||
|
import TopicSidebar from './components/TopicSidebar.vue'
|
||||||
|
import TopicDetail from './components/TopicDetail.vue'
|
||||||
|
import BausteineView from './components/BausteineView.vue'
|
||||||
|
import HelpChat from './components/HelpChat.vue'
|
||||||
|
|
||||||
|
const guides = ref([])
|
||||||
|
const projects = ref([])
|
||||||
|
const manualTopics = ref([])
|
||||||
|
const selectedTopic = ref(null)
|
||||||
|
const previewGuide = ref(null)
|
||||||
|
const showBausteine = ref(false)
|
||||||
|
const showHelp = ref(false)
|
||||||
|
const bausteineRefreshKey = ref(0)
|
||||||
|
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
|
||||||
|
const sidebarSticky = ref(false)
|
||||||
|
const darkMode = ref(
|
||||||
|
localStorage.getItem('darkMode') === null
|
||||||
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
: localStorage.getItem('darkMode') === 'true',
|
||||||
|
)
|
||||||
|
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||||
|
const providers = ref([])
|
||||||
|
|
||||||
|
function setProvider(id) {
|
||||||
|
provider.value = id
|
||||||
|
localStorage.setItem('provider', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProviders() {
|
||||||
|
try {
|
||||||
|
providers.value = await fetchProviders()
|
||||||
|
const current = providers.value.find((p) => p.id === provider.value)
|
||||||
|
if (current && !current.available) {
|
||||||
|
const fallback = providers.value.find((p) => p.available)
|
||||||
|
if (fallback) setProvider(fallback.id)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden der Provider:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pollTimer = null
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
document.documentElement.classList.toggle('dark', darkMode.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDark() {
|
||||||
|
darkMode.value = !darkMode.value
|
||||||
|
localStorage.setItem('darkMode', darkMode.value ? 'true' : 'false')
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme()
|
||||||
|
|
||||||
|
function toggleSidebarPin() {
|
||||||
|
sidebarPinned.value = !sidebarPinned.value
|
||||||
|
localStorage.setItem('sidebarPinned', sidebarPinned.value ? 'true' : 'false')
|
||||||
|
if (sidebarPinned.value) sidebarSticky.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickHoverZone() {
|
||||||
|
sidebarSticky.value = !sidebarSticky.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSidebarLeave() {
|
||||||
|
if (!sidebarPinned.value) sidebarSticky.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectNames = computed(() => projects.value.map((p) => p.name))
|
||||||
|
|
||||||
|
const topics = computed(() => {
|
||||||
|
const isProject = new Set(projectNames.value)
|
||||||
|
const topicDates = {}
|
||||||
|
for (const g of guides.value) {
|
||||||
|
if (isProject.has(g.topic)) continue
|
||||||
|
if (!topicDates[g.topic] || g.created_at > topicDates[g.topic]) {
|
||||||
|
topicDates[g.topic] = g.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of manualTopics.value) {
|
||||||
|
if (isProject.has(t)) continue
|
||||||
|
if (!topicDates[t]) topicDates[t] = new Date().toISOString()
|
||||||
|
}
|
||||||
|
return Object.keys(topicDates).sort((a, b) => topicDates[b].localeCompare(topicDates[a]))
|
||||||
|
})
|
||||||
|
|
||||||
|
const isProjectSelected = computed(() => projectNames.value.includes(selectedTopic.value))
|
||||||
|
|
||||||
|
const doneByFormat = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const g of guides.value) {
|
||||||
|
if (g.topic !== selectedTopic.value) continue
|
||||||
|
if (g.status !== 'done') continue
|
||||||
|
if (!map[g.format] || g.created_at > map[g.format].created_at) {
|
||||||
|
map[g.format] = g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestByFormat = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
projects.value = await fetchProjects()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden der Projekte:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_ORDER = ['OnePager', 'Cheatsheet', 'MiniGuide', 'Guide', 'EndGuide']
|
||||||
|
|
||||||
|
function autoPreview() {
|
||||||
|
const map = doneByFormat.value
|
||||||
|
for (const f of FORMAT_ORDER) {
|
||||||
|
if (map[f]) {
|
||||||
|
previewGuide.value = map[f]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewGuide.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTopic(topic) {
|
||||||
|
selectedTopic.value = topic
|
||||||
|
previewGuide.value = null
|
||||||
|
showBausteine.value = false
|
||||||
|
showHelp.value = false
|
||||||
|
sidebarSticky.value = false
|
||||||
|
nextTick(autoPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTopic(topic) {
|
||||||
|
if (!manualTopics.value.includes(topic)) {
|
||||||
|
manualTopics.value.push(topic)
|
||||||
|
}
|
||||||
|
selectedTopic.value = topic
|
||||||
|
previewGuide.value = null
|
||||||
|
showHelp.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHelpSelect(title) {
|
||||||
|
createTopic(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFormatClick({ format, instructions, reindex }) {
|
||||||
|
if (!selectedTopic.value) return
|
||||||
|
await apiCreate(selectedTopic.value, format, instructions, reindex || false, provider.value)
|
||||||
|
await loadGuides()
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteProject(name) {
|
||||||
|
await apiDeleteProject(name)
|
||||||
|
if (selectedTopic.value === name) {
|
||||||
|
selectedTopic.value = null
|
||||||
|
previewGuide.value = null
|
||||||
|
}
|
||||||
|
await loadProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRework({ guideId, instructions }) {
|
||||||
|
await apiRework(guideId, instructions, provider.value)
|
||||||
|
await loadGuides()
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreview(guide) {
|
||||||
|
previewGuide.value = guide
|
||||||
|
showBausteine.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShowBausteine() {
|
||||||
|
showBausteine.value = true
|
||||||
|
previewGuide.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSidebarAddBaustein(title) {
|
||||||
|
if (!selectedTopic.value) return
|
||||||
|
await apiCreateBaustein(selectedTopic.value, title, '', provider.value)
|
||||||
|
bausteineRefreshKey.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Promise.all([loadGuides(), loadProjects(), loadProviders()])
|
||||||
|
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" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky }">
|
||||||
|
<div v-if="!sidebarPinned" class="hover-zone" @click="clickHoverZone"></div>
|
||||||
|
<div v-if="!sidebarPinned && sidebarSticky" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
|
||||||
|
<TopicSidebar
|
||||||
|
:topics="topics"
|
||||||
|
:projects="projectNames"
|
||||||
|
:isProjectSelected="isProjectSelected"
|
||||||
|
:selectedTopic="selectedTopic"
|
||||||
|
:doneByFormat="doneByFormat"
|
||||||
|
:latestByFormat="latestByFormat"
|
||||||
|
:allGuides="guides"
|
||||||
|
:bausteineActive="showBausteine"
|
||||||
|
:pinned="sidebarPinned"
|
||||||
|
:dark="darkMode"
|
||||||
|
:provider="provider"
|
||||||
|
:providers="providers"
|
||||||
|
@setProvider="setProvider"
|
||||||
|
@toggleDark="toggleDark"
|
||||||
|
@select="selectTopic"
|
||||||
|
@create="createTopic"
|
||||||
|
@formatClick="handleFormatClick"
|
||||||
|
@deleteTopic="handleDeleteTopic"
|
||||||
|
@deleteProject="handleDeleteProject"
|
||||||
|
@cancelGuide="handleCancel"
|
||||||
|
@deleteGuide="handleDeleteGuide"
|
||||||
|
@preview="handlePreview"
|
||||||
|
@rework="handleRework"
|
||||||
|
@showBausteine="handleShowBausteine"
|
||||||
|
@addBaustein="handleSidebarAddBaustein"
|
||||||
|
@togglePin="toggleSidebarPin"
|
||||||
|
@sidebarLeave="onSidebarLeave"
|
||||||
|
@openHelp="showHelp = true"
|
||||||
|
/>
|
||||||
|
<HelpChat
|
||||||
|
v-if="showHelp"
|
||||||
|
:provider="provider"
|
||||||
|
@close="showHelp = false"
|
||||||
|
@selectTopic="onHelpSelect"
|
||||||
|
/>
|
||||||
|
<BausteineView
|
||||||
|
v-else-if="selectedTopic && showBausteine"
|
||||||
|
:topic="selectedTopic"
|
||||||
|
:refreshKey="bausteineRefreshKey"
|
||||||
|
:provider="provider"
|
||||||
|
/>
|
||||||
|
<TopicDetail
|
||||||
|
v-else-if="selectedTopic"
|
||||||
|
:previewGuide="previewGuide"
|
||||||
|
:dark="darkMode"
|
||||||
|
:provider="provider"
|
||||||
|
/>
|
||||||
|
<div v-else class="empty-main">
|
||||||
|
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f8f9fb;
|
||||||
|
--bg-preview: #f0f1f4;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-soft: #f4f5f7;
|
||||||
|
--border: #e2e5e9;
|
||||||
|
--border-strong: #d8dde3;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #4b5563;
|
||||||
|
--text-faint: #9ca3af;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #4f46e5;
|
||||||
|
--accent-soft: #ede9fe;
|
||||||
|
--accent-border: #a5b4fc;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--success: #065f46;
|
||||||
|
--success-soft: #d1fae5;
|
||||||
|
--success-soft-hover: #a7f3d0;
|
||||||
|
--success-border: #34d399;
|
||||||
|
--warning: #92400e;
|
||||||
|
--warning-soft: #fef3c7;
|
||||||
|
--warning-border: #fbbf24;
|
||||||
|
--danger: #991b1b;
|
||||||
|
--code-bg: #1e2a3a;
|
||||||
|
--code-fg: #e6e6e6;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
--bg: #15171c;
|
||||||
|
--bg-preview: #0e1014;
|
||||||
|
--panel: #1c1f26;
|
||||||
|
--panel-soft: #23262e;
|
||||||
|
--border: #2c3038;
|
||||||
|
--border-strong: #3a3f4a;
|
||||||
|
--text: #e6e8ee;
|
||||||
|
--text-muted: #9aa3b2;
|
||||||
|
--text-faint: #6b7280;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #818cf8;
|
||||||
|
--accent-soft: #2a2350;
|
||||||
|
--accent-border: #4f46e5;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--success: #34d399;
|
||||||
|
--success-soft: #0f3a2e;
|
||||||
|
--success-soft-hover: #155e45;
|
||||||
|
--success-border: #0f805e;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-soft: #3a2c0a;
|
||||||
|
--warning-border: #a06a12;
|
||||||
|
--danger: #f87171;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-zone {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 50px;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 5;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unsichtbare Fläche hinter der offenen Floating-Sidebar.
|
||||||
|
Tipp/Klick daneben schließt sie — ohne sie gibt es auf Touch keinen Ausweg. */
|
||||||
|
.sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout.sidebar-floating > .sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 0 16px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout.sidebar-floating .hover-zone:hover ~ .sidebar,
|
||||||
|
.layout.sidebar-floating > .sidebar:hover,
|
||||||
|
.layout.sidebar-floating.sidebar-open > .sidebar {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
153
frontend/src/api.js
Normal file
153
frontend/src/api.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
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, instructions = '', reindex = false, provider = 'claude') {
|
||||||
|
const res = await fetch(`${BASE}/guides`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ topic, format, instructions, reindex, provider }),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProviders() {
|
||||||
|
const res = await fetch(`${BASE}/providers`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjects() {
|
||||||
|
const res = await fetch(`${BASE}/projects`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProject(name) {
|
||||||
|
await fetch(`${BASE}/projects/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reworkGuide(id, instructions, provider = 'claude') {
|
||||||
|
const res = await fetch(`${BASE}/guides/${id}/rework`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ instructions, provider }),
|
||||||
|
})
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProgress(id) {
|
||||||
|
const res = await fetch(`${BASE}/guides/${id}/progress`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setProgress(id, chapter, done) {
|
||||||
|
const res = await fetch(`${BASE}/guides/${id}/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ chapter, done }),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
|
||||||
|
const res = await fetch(`${BASE}/guides/${id}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ section, outline, messages, provider }),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function suggestTopics(problem, provider = 'claude') {
|
||||||
|
const res = await fetch(`${BASE}/topic-suggestions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ problem, provider }),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBausteine(topic) {
|
||||||
|
const res = await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBaustein(topic, title, instructions = '', provider = 'claude') {
|
||||||
|
const res = await fetch(`${BASE}/bausteine`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ topic, title, instructions, provider }),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBaustein(id) {
|
||||||
|
await fetch(`${BASE}/bausteine/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reworkBaustein(id, instructions, provider = 'claude') {
|
||||||
|
await fetch(`${BASE}/bausteine/${id}/rework`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ instructions, provider }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sortBausteine(topic, instructions = '', provider = 'claude') {
|
||||||
|
await fetch(`${BASE}/bausteine/sort?topic=${encodeURIComponent(topic)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ instructions, provider }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSortStatus(topic) {
|
||||||
|
const res = await fetch(`${BASE}/bausteine/sort/status?topic=${encodeURIComponent(topic)}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSuggestions(topic) {
|
||||||
|
const res = await fetch(`${BASE}/bausteine/suggestions?topic=${encodeURIComponent(topic)}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSuggestions(topic, provider = 'claude') {
|
||||||
|
await fetch(`${BASE}/bausteine/suggestions/generate?topic=${encodeURIComponent(topic)}&provider=${encodeURIComponent(provider)}`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSuggestionsStatus(topic) {
|
||||||
|
const res = await fetch(`${BASE}/bausteine/suggestions/status?topic=${encodeURIComponent(topic)}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addSuggestion(id) {
|
||||||
|
const res = await fetch(`${BASE}/bausteine/suggestions/${id}/add`, { method: 'POST' })
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ignoreSuggestion(id) {
|
||||||
|
await fetch(`${BASE}/bausteine/suggestions/${id}/ignore`, { method: 'POST' })
|
||||||
|
}
|
||||||
649
frontend/src/components/BausteineView.vue
Normal file
649
frontend/src/components/BausteineView.vue
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import {
|
||||||
|
fetchBausteine,
|
||||||
|
createBaustein,
|
||||||
|
deleteBaustein,
|
||||||
|
reworkBaustein,
|
||||||
|
sortBausteine,
|
||||||
|
fetchSortStatus,
|
||||||
|
fetchSuggestions,
|
||||||
|
generateSuggestions,
|
||||||
|
fetchSuggestionsStatus,
|
||||||
|
addSuggestion,
|
||||||
|
ignoreSuggestion,
|
||||||
|
} from '../api.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
topic: { type: String, required: true },
|
||||||
|
refreshKey: { type: Number, default: 0 },
|
||||||
|
provider: { type: String, default: 'claude' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const bausteine = ref([])
|
||||||
|
const suggestions = ref([])
|
||||||
|
const suggestionsLoading = ref(false)
|
||||||
|
const newTitle = ref('')
|
||||||
|
const newInfo = ref('')
|
||||||
|
const sortInfo = ref('')
|
||||||
|
const sortingActive = ref(false)
|
||||||
|
let sortPollTimer = null
|
||||||
|
const reworkInputs = ref({})
|
||||||
|
const reworkingIds = ref(new Set())
|
||||||
|
const reworkingSnapshots = new Map()
|
||||||
|
let pollTimer = null
|
||||||
|
let bausteinPollTimer = null
|
||||||
|
|
||||||
|
const pendingSuggestions = computed(() => suggestions.value.filter((s) => s.status === 'pending'))
|
||||||
|
const ignoredSuggestions = computed(() => suggestions.value.filter((s) => s.status === 'ignored'))
|
||||||
|
|
||||||
|
function parseExamples(example) {
|
||||||
|
if (!example) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(example)
|
||||||
|
if (Array.isArray(parsed)) return parsed
|
||||||
|
} catch {}
|
||||||
|
return [{ label: '', code: example }]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const [b, s] = await Promise.all([fetchBausteine(props.topic), fetchSuggestions(props.topic)])
|
||||||
|
bausteine.value = b
|
||||||
|
suggestions.value = s
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAndGenerate() {
|
||||||
|
const status = await fetchSuggestionsStatus(props.topic)
|
||||||
|
if (status.generating) {
|
||||||
|
suggestionsLoading.value = true
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
const title = newTitle.value.trim()
|
||||||
|
if (!title) return
|
||||||
|
const info = newInfo.value.trim()
|
||||||
|
newTitle.value = ''
|
||||||
|
newInfo.value = ''
|
||||||
|
const created = await createBaustein(props.topic, title, info, props.provider)
|
||||||
|
bausteine.value.push(created)
|
||||||
|
reworkingSnapshots.set(created.id, created.updated_at)
|
||||||
|
reworkingIds.value = new Set([...reworkingIds.value, created.id])
|
||||||
|
startBausteinPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
await deleteBaustein(id)
|
||||||
|
bausteine.value = bausteine.value.filter((b) => b.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccept(s) {
|
||||||
|
const created = await addSuggestion(s.id)
|
||||||
|
suggestions.value = suggestions.value.filter((x) => x.id !== s.id)
|
||||||
|
bausteine.value.push(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIgnore(s) {
|
||||||
|
await ignoreSuggestion(s.id)
|
||||||
|
const idx = suggestions.value.findIndex((x) => x.id === s.id)
|
||||||
|
if (idx !== -1) suggestions.value[idx].status = 'ignored'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore(s) {
|
||||||
|
const created = await addSuggestion(s.id)
|
||||||
|
suggestions.value = suggestions.value.filter((x) => x.id !== s.id)
|
||||||
|
bausteine.value.push(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate() {
|
||||||
|
suggestionsLoading.value = true
|
||||||
|
await generateSuggestions(props.topic, props.provider)
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSort() {
|
||||||
|
sortingActive.value = true
|
||||||
|
const info = sortInfo.value.trim()
|
||||||
|
await sortBausteine(props.topic, info, props.provider)
|
||||||
|
startSortPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSortPolling() {
|
||||||
|
if (sortPollTimer) return
|
||||||
|
sortPollTimer = setInterval(async () => {
|
||||||
|
const status = await fetchSortStatus(props.topic)
|
||||||
|
if (!status.sorting) {
|
||||||
|
sortingActive.value = false
|
||||||
|
clearInterval(sortPollTimer)
|
||||||
|
sortPollTimer = null
|
||||||
|
bausteine.value = await fetchBausteine(props.topic)
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRework(b) {
|
||||||
|
const instructions = (reworkInputs.value[b.id] || '').trim()
|
||||||
|
if (!instructions) return
|
||||||
|
reworkingSnapshots.set(b.id, b.updated_at)
|
||||||
|
reworkingIds.value = new Set([...reworkingIds.value, b.id])
|
||||||
|
reworkInputs.value[b.id] = ''
|
||||||
|
await reworkBaustein(b.id, instructions, props.provider)
|
||||||
|
startBausteinPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBausteinPolling() {
|
||||||
|
if (bausteinPollTimer) return
|
||||||
|
bausteinPollTimer = setInterval(async () => {
|
||||||
|
const fresh = await fetchBausteine(props.topic)
|
||||||
|
bausteine.value = fresh
|
||||||
|
const remaining = new Set(reworkingIds.value)
|
||||||
|
for (const id of reworkingIds.value) {
|
||||||
|
const b = fresh.find((x) => x.id === id)
|
||||||
|
if (!b || b.updated_at !== reworkingSnapshots.get(id)) {
|
||||||
|
remaining.delete(id)
|
||||||
|
reworkingSnapshots.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reworkingIds.value = remaining
|
||||||
|
if (remaining.size === 0) {
|
||||||
|
clearInterval(bausteinPollTimer)
|
||||||
|
bausteinPollTimer = null
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling()
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
const status = await fetchSuggestionsStatus(props.topic)
|
||||||
|
if (!status.generating) {
|
||||||
|
suggestionsLoading.value = false
|
||||||
|
stopPolling()
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
if (bausteinPollTimer) {
|
||||||
|
clearInterval(bausteinPollTimer)
|
||||||
|
bausteinPollTimer = null
|
||||||
|
}
|
||||||
|
if (sortPollTimer) {
|
||||||
|
clearInterval(sortPollTimer)
|
||||||
|
sortPollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await loadData()
|
||||||
|
await checkAndGenerate()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.topic,
|
||||||
|
() => {
|
||||||
|
stopPolling()
|
||||||
|
suggestionsLoading.value = false
|
||||||
|
init()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.refreshKey,
|
||||||
|
async (newVal) => {
|
||||||
|
if (!newVal) return
|
||||||
|
await loadData()
|
||||||
|
let added = false
|
||||||
|
for (const b of bausteine.value) {
|
||||||
|
if (!b.description && !b.purpose && !reworkingIds.value.has(b.id)) {
|
||||||
|
reworkingSnapshots.set(b.id, b.updated_at)
|
||||||
|
reworkingIds.value = new Set([...reworkingIds.value, b.id])
|
||||||
|
added = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added) startBausteinPolling()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
onUnmounted(stopPolling)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bausteine-view">
|
||||||
|
<div class="bausteine-grid">
|
||||||
|
<div class="card new-card">
|
||||||
|
<h3>Neuer Baustein</h3>
|
||||||
|
<input
|
||||||
|
v-model="newTitle"
|
||||||
|
placeholder="Thema…"
|
||||||
|
@keyup.enter="handleAdd"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="newInfo"
|
||||||
|
placeholder="Zusätzliche Infos (optional)…"
|
||||||
|
@keyup.enter="handleAdd"
|
||||||
|
/>
|
||||||
|
<button class="new-btn" @click="handleAdd" :disabled="!newTitle.trim()">Generieren</button>
|
||||||
|
|
||||||
|
<h3 class="new-card-section">Ordnen</h3>
|
||||||
|
<input
|
||||||
|
v-model="sortInfo"
|
||||||
|
placeholder="Zusätzliche Infos (optional)…"
|
||||||
|
@keyup.enter="handleSort"
|
||||||
|
/>
|
||||||
|
<button class="new-btn" @click="handleSort" :disabled="sortingActive || !bausteine.length">
|
||||||
|
{{ sortingActive ? 'Ordne…' : 'Ordnen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="b in bausteine" :key="b.id" class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>{{ b.title }}</h3>
|
||||||
|
<button class="card-delete" @click="handleDelete(b.id)">×</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="b.description" class="desc">{{ b.description }}</p>
|
||||||
|
<p v-if="b.purpose" class="purpose">{{ b.purpose }}</p>
|
||||||
|
<div v-if="b.example" class="examples">
|
||||||
|
<div v-for="(ex, i) in parseExamples(b.example)" :key="i" class="code-block">
|
||||||
|
<span v-if="ex.label" class="code-label">{{ ex.label }}</span>
|
||||||
|
<pre v-html="ex.code"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!b.description && !b.purpose" class="loading-text">Wird generiert…</p>
|
||||||
|
<p v-else-if="reworkingIds.has(b.id)" class="loading-text">Wird überarbeitet…</p>
|
||||||
|
<div class="rework-row">
|
||||||
|
<input
|
||||||
|
v-model="reworkInputs[b.id]"
|
||||||
|
placeholder="Anpassen…"
|
||||||
|
:disabled="reworkingIds.has(b.id)"
|
||||||
|
@keyup.enter="handleRework(b)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rework-btn"
|
||||||
|
:disabled="!reworkInputs[b.id]?.trim() || reworkingIds.has(b.id)"
|
||||||
|
@click="handleRework(b)"
|
||||||
|
title="Überarbeiten"
|
||||||
|
>↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider" />
|
||||||
|
|
||||||
|
<div class="suggestions-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Vorschläge</h3>
|
||||||
|
<button class="regenerate-btn" @click="handleRegenerate" :disabled="suggestionsLoading">
|
||||||
|
{{ suggestionsLoading ? 'Generiere…' : 'Generieren' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="suggestionsLoading" class="loading-indicator">Generiere Vorschläge…</div>
|
||||||
|
<div v-if="pendingSuggestions.length" class="suggestions-grid">
|
||||||
|
<div v-for="s in pendingSuggestions" :key="s.id" class="card suggestion-card">
|
||||||
|
<h3>{{ s.title }}</h3>
|
||||||
|
<p v-if="s.description" class="desc">{{ s.description }}</p>
|
||||||
|
<p v-if="s.purpose" class="purpose">{{ s.purpose }}</p>
|
||||||
|
<div v-if="s.example" class="examples">
|
||||||
|
<div v-for="(ex, i) in parseExamples(s.example)" :key="i" class="code-block">
|
||||||
|
<span v-if="ex.label" class="code-label">{{ ex.label }}</span>
|
||||||
|
<pre v-html="ex.code"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="suggestion-actions">
|
||||||
|
<button class="btn-add" @click="handleAccept(s)">Hinzufügen</button>
|
||||||
|
<button class="btn-ignore" @click="handleIgnore(s)">Ignorieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr v-if="ignoredSuggestions.length" class="divider" />
|
||||||
|
|
||||||
|
<div v-if="ignoredSuggestions.length" class="ignored-section">
|
||||||
|
<h3>Ignoriert</h3>
|
||||||
|
<div v-for="s in ignoredSuggestions" :key="s.id" class="ignored-item">
|
||||||
|
<span>{{ s.title }}</span>
|
||||||
|
<button class="btn-restore" @click="handleRestore(s)">Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bausteine-view {
|
||||||
|
flex: 1;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bausteine-grid,
|
||||||
|
.suggestions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-card {
|
||||||
|
background: var(--bg);
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-card h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-card-section {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-card input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-card input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .card-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purpose {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.examples {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--code-fg);
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-label {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #8be9fd;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block :deep(.k) { color: #ff79c6; }
|
||||||
|
.code-block :deep(.v) { color: #ffb86c; }
|
||||||
|
.code-block :deep(.s) { color: #f1c40f; }
|
||||||
|
.code-block :deep(.f) { color: #50fa7b; }
|
||||||
|
.code-block :deep(.t) { color: #8be9fd; }
|
||||||
|
.code-block :deep(.c) { color: #6b8aae; font-style: italic; }
|
||||||
|
.code-block :deep(.n) { color: #ffb86c; }
|
||||||
|
.code-block :deep(.p) { color: var(--code-fg); }
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rework-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rework-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rework-row input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rework-row input:disabled {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rework-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rework-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rework-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regenerate-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--panel);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.regenerate-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.regenerate-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-card {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--success-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--success-soft);
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover {
|
||||||
|
background: var(--success-soft-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ignore {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ignore:hover {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ignored-section h3 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ignored-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-restore {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-restore:hover {
|
||||||
|
border-color: var(--success-border);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.65; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
218
frontend/src/components/HelpChat.vue
Normal file
218
frontend/src/components/HelpChat.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { suggestTopics } from '../api.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
provider: { type: String, default: 'claude' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'selectTopic'])
|
||||||
|
|
||||||
|
const problem = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const suggestions = ref([])
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const text = problem.value.trim()
|
||||||
|
if (!text || loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
submitted.value = true
|
||||||
|
error.value = false
|
||||||
|
suggestions.value = []
|
||||||
|
try {
|
||||||
|
const result = await suggestTopics(text, props.provider)
|
||||||
|
suggestions.value = Array.isArray(result) ? result : []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(title) {
|
||||||
|
emit('selectTopic', title)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="help-chat">
|
||||||
|
<header class="help-header">
|
||||||
|
<h2>Passendes Thema finden</h2>
|
||||||
|
<button class="close-btn" title="Schließen" @click="emit('close')">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="help-body">
|
||||||
|
<p class="hint">Beschreibe dein Problem oder Lernziel – die KI schlägt dir passende Themen-Namen vor, zu denen du einen Guide generieren kannst.</p>
|
||||||
|
|
||||||
|
<div class="input-row">
|
||||||
|
<textarea
|
||||||
|
v-model="problem"
|
||||||
|
placeholder="Womit hast du Probleme? Was möchtest du lernen?"
|
||||||
|
:disabled="loading"
|
||||||
|
@keyup.enter.exact.prevent="submit"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class="send-btn"
|
||||||
|
:disabled="!problem.trim() || loading"
|
||||||
|
@click="submit"
|
||||||
|
>{{ loading ? '…' : 'Themen finden' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="status">Suche Themen…</div>
|
||||||
|
<div v-else-if="error" class="status error">Fehler bei der Anfrage. Bitte erneut versuchen.</div>
|
||||||
|
<div v-else-if="submitted && !suggestions.length" class="status">Keine Vorschläge gefunden.</div>
|
||||||
|
|
||||||
|
<ul v-else class="suggestions">
|
||||||
|
<li v-for="(s, i) in suggestions" :key="i" class="suggestion" @click="pick(s.title)">
|
||||||
|
<span class="suggestion-title">{{ s.title }}</span>
|
||||||
|
<span class="suggestion-reason" v-if="s.reason">{{ s.reason }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.help-chat {
|
||||||
|
flex: 1;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-header h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 760px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 70px;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row textarea:disabled {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
list-style: none;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--success-soft);
|
||||||
|
border: 1px solid var(--success-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion:hover {
|
||||||
|
background: var(--success-soft-hover);
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-reason {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
637
frontend/src/components/TopicDetail.vue
Normal file
637
frontend/src/components/TopicDetail.vue
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { htmlUrl, chatGuide, fetchProgress, setProgress } from '../api.js'
|
||||||
|
|
||||||
|
marked.setOptions({ breaks: true, gfm: true })
|
||||||
|
|
||||||
|
function renderMarkdown(text) {
|
||||||
|
return DOMPurify.sanitize(marked.parse(text || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
previewGuide: { type: Object, default: null },
|
||||||
|
dark: { type: Boolean, default: false },
|
||||||
|
provider: { type: String, default: 'claude' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const LANDSCAPE_FORMATS = ['OnePager', 'Cheatsheet']
|
||||||
|
const isLandscape = computed(() => LANDSCAPE_FORMATS.includes(props.previewGuide?.format))
|
||||||
|
|
||||||
|
const frameEl = ref(null)
|
||||||
|
|
||||||
|
// --- Kapitel-Fortschritt ---
|
||||||
|
function onFrameLoad(e) {
|
||||||
|
const doc = e.target.contentDocument
|
||||||
|
if (!doc) return
|
||||||
|
injectStyles(doc)
|
||||||
|
applyIframeTheme(doc)
|
||||||
|
setupProgress(doc)
|
||||||
|
// Klicks im iframe blubbern nicht zum Eltern-document → eigener Listener (same-origin)
|
||||||
|
doc.addEventListener('mousedown', onFrameMouseDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guide-Dokument folgt dem App-Theme über das data-theme-Attribut.
|
||||||
|
// Guides definieren :root-Variablen (--ink, --muted, --line, --bg-soft);
|
||||||
|
// der injizierte Dark-Block (s. injectStyles) greift nur mit gesetztem Attribut.
|
||||||
|
function applyIframeTheme(doc) {
|
||||||
|
if (props.dark) doc.documentElement.setAttribute('data-theme', 'dark')
|
||||||
|
else doc.documentElement.removeAttribute('data-theme')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.dark, () => {
|
||||||
|
const doc = frameEl.value?.contentDocument
|
||||||
|
if (doc) applyIframeTheme(doc)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onFrameMouseDown() {
|
||||||
|
if (chatOpen.value) closeChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectStyles(doc) {
|
||||||
|
// Ohne viewport-meta rendert das iframe-Dokument mit Desktop-Breite und greift unsere max-width-Query nicht.
|
||||||
|
if (!doc.querySelector('meta[name="viewport"]')) {
|
||||||
|
const meta = doc.createElement('meta')
|
||||||
|
meta.name = 'viewport'
|
||||||
|
meta.content = 'width=device-width, initial-scale=1'
|
||||||
|
doc.head?.appendChild(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = doc.createElement('style')
|
||||||
|
const portrait = !isLandscape.value
|
||||||
|
? `html { background: #f0f1f4; }
|
||||||
|
body {
|
||||||
|
zoom: 1.15;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.5rem 3.5rem;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}`
|
||||||
|
: ''
|
||||||
|
// Generischer Dark-Override: greift nur, wenn die App data-theme="dark" setzt.
|
||||||
|
// Entfällt, wenn der Guide eigene Dark-Regeln mitbringt (neuere Templates).
|
||||||
|
const hasOwnDark = (doc.head?.innerHTML || '').includes('data-theme="dark"')
|
||||||
|
const darkOverride = hasOwnDark
|
||||||
|
? ''
|
||||||
|
: `html[data-theme="dark"] {
|
||||||
|
--ink: #e6e8ee;
|
||||||
|
--muted: #9aa3b2;
|
||||||
|
--line: #2c3038;
|
||||||
|
--bg-soft: #23262e;
|
||||||
|
background: #0e1014;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] body {
|
||||||
|
background: #1c1f26;
|
||||||
|
color: var(--ink, #e6e8ee);
|
||||||
|
}
|
||||||
|
/* Callouts haben hartkodierte helle Hintergründe (#e8f4ea u. ä.) */
|
||||||
|
html[data-theme="dark"] .callout {
|
||||||
|
background: var(--bg-soft, #23262e);
|
||||||
|
}`
|
||||||
|
style.textContent = `@media screen {
|
||||||
|
${portrait}
|
||||||
|
${darkOverride}
|
||||||
|
.ch-toggle {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 2.5rem 0 0.5rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1.5px dashed var(--line, #c7ccd6);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-soft, #f8f9fb);
|
||||||
|
color: var(--muted, #4b5563);
|
||||||
|
font: 600 0.95rem/1.2 -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.ch-toggle:hover { border-color: #6366f1; color: #6366f1; background: transparent; }
|
||||||
|
.ch-toggle.is-done {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #34d399;
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .ch-toggle.is-done {
|
||||||
|
border-color: #0f805e;
|
||||||
|
background: #0f3a2e;
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
section.chapter.ch-complete > :not(.ch-toggle) { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 700px) {
|
||||||
|
html { background: #fff; }
|
||||||
|
body {
|
||||||
|
zoom: 1 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 1.25rem 1rem !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover/Part-Divider: feste mm-Höhen + flex-center fürs Print-A4. Auf Mobile läuft
|
||||||
|
zentrierter Inhalt über die Box hinaus und überlappt das nächste Element.
|
||||||
|
→ als normale Blöcke von oben fließen lassen. */
|
||||||
|
.cover, .part-divider {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
padding: 2.5rem 1.25rem !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cover h1 { font-size: 30pt !important; }
|
||||||
|
.cover h1 .light { font-size: 22pt !important; }
|
||||||
|
.cover .sub { font-size: 12pt !important; max-width: 100% !important; margin-bottom: 1.5rem !important; }
|
||||||
|
.cover .meta-row { flex-wrap: wrap; gap: 0.75rem 1.5rem !important; }
|
||||||
|
.cover-deco { font-size: 60pt !important; right: 1rem !important; }
|
||||||
|
|
||||||
|
.part-divider h1 { font-size: 26pt !important; }
|
||||||
|
.part-divider .part-desc { max-width: 100% !important; }
|
||||||
|
|
||||||
|
.chapter-head h1 { font-size: 22pt !important; }
|
||||||
|
|
||||||
|
/* Print schneidet Code hart ab (overflow: hidden) → auf Mobile umbrechen + scrollbar */
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blocksatz erzeugt riesige Wortlücken in schmalen Spalten */
|
||||||
|
p { text-align: left !important; }
|
||||||
|
|
||||||
|
/* Breite Tabellen horizontal scrollbar statt überlaufend */
|
||||||
|
table { display: block; width: 100%; overflow-x: auto; }
|
||||||
|
}`
|
||||||
|
doc.head?.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupProgress(doc) {
|
||||||
|
const guideId = props.previewGuide?.id
|
||||||
|
const chapters = Array.from(doc.querySelectorAll('section.chapter'))
|
||||||
|
if (!guideId || !chapters.length) return
|
||||||
|
|
||||||
|
let done = new Set()
|
||||||
|
try {
|
||||||
|
const res = await fetchProgress(guideId)
|
||||||
|
done = new Set(res.chapters || [])
|
||||||
|
} catch { /* offline → leer */ }
|
||||||
|
|
||||||
|
chapters.forEach((section, i) => {
|
||||||
|
if (section.querySelector(':scope > .ch-toggle')) return // Guard gegen Doppel-Inject
|
||||||
|
const numEl = section.querySelector('.chapter-num')
|
||||||
|
const key = (numEl?.textContent.match(/\d+/)?.[0]) || String(i + 1)
|
||||||
|
|
||||||
|
const toggle = doc.createElement('div')
|
||||||
|
toggle.className = 'ch-toggle'
|
||||||
|
const apply = (isDone) => {
|
||||||
|
toggle.classList.toggle('is-done', isDone)
|
||||||
|
section.classList.toggle('ch-complete', isDone)
|
||||||
|
toggle.textContent = isDone ? '✓ Erledigt – rückgängig' : 'Kapitel als erledigt markieren'
|
||||||
|
}
|
||||||
|
apply(done.has(key))
|
||||||
|
|
||||||
|
toggle.addEventListener('click', async () => {
|
||||||
|
const newState = !section.classList.contains('ch-complete')
|
||||||
|
apply(newState)
|
||||||
|
try {
|
||||||
|
await setProgress(guideId, key, newState)
|
||||||
|
} catch {
|
||||||
|
apply(!newState) // Rollback bei Fehler
|
||||||
|
}
|
||||||
|
})
|
||||||
|
section.appendChild(toggle)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zum ersten noch offenen Kapitel springen — aber nur, wenn davor schon was erledigt ist.
|
||||||
|
// Frischer Guide (nichts erledigt) bleibt oben beim Cover.
|
||||||
|
const firstOpen = chapters.find((s) => !s.classList.contains('ch-complete'))
|
||||||
|
if (firstOpen && firstOpen !== chapters[0]) {
|
||||||
|
firstOpen.scrollIntoView({ block: 'start' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chat ---
|
||||||
|
const chatOpen = ref(false)
|
||||||
|
const messages = ref([])
|
||||||
|
const input = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const messagesEl = ref(null)
|
||||||
|
const inputEl = ref(null)
|
||||||
|
const panelEl = ref(null)
|
||||||
|
|
||||||
|
function openChat() {
|
||||||
|
chatOpen.value = true
|
||||||
|
nextTick(() => inputEl.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChat() {
|
||||||
|
chatOpen.value = false
|
||||||
|
messages.value = []
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klicks außerhalb des iframes (Sidebar, Ränder) — mousedown vermeidet die Open/Close-Race beim FAB
|
||||||
|
function onDocMouseDown(e) {
|
||||||
|
if (!chatOpen.value) return
|
||||||
|
if (panelEl.value && panelEl.value.contains(e.target)) return
|
||||||
|
closeChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter öffnet den Chat (wenn zu, nicht in Eingabefeld); ESC schließt ihn
|
||||||
|
function onDocKeyDown(e) {
|
||||||
|
if (e.key === 'Escape' && chatOpen.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
closeChat()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Enter' || chatOpen.value || !props.previewGuide) return
|
||||||
|
const tag = document.activeElement?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||||
|
e.preventDefault()
|
||||||
|
openChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', onDocMouseDown, true)
|
||||||
|
document.addEventListener('keydown', onDocKeyDown)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', onDocMouseDown, true)
|
||||||
|
document.removeEventListener('keydown', onDocKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
function extractContext() {
|
||||||
|
try {
|
||||||
|
const doc = frameEl.value?.contentDocument
|
||||||
|
if (!doc) return { section: '', outline: '' }
|
||||||
|
const headings = Array.from(doc.querySelectorAll('h1, h2, h3'))
|
||||||
|
if (!headings.length) return { section: '', outline: '' }
|
||||||
|
|
||||||
|
const outline = headings.map((h) => h.innerText.trim()).filter(Boolean).join('\n')
|
||||||
|
|
||||||
|
// Aktuelle Überschrift = letzte, die oben im sichtbaren Bereich oder darüber liegt
|
||||||
|
let current = headings[0]
|
||||||
|
for (const h of headings) {
|
||||||
|
if (h.getBoundingClientRect().top <= 100) current = h
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
const level = Number(current.tagName[1])
|
||||||
|
const idx = headings.indexOf(current)
|
||||||
|
let end = null
|
||||||
|
for (let i = idx + 1; i < headings.length; i++) {
|
||||||
|
if (Number(headings[i].tagName[1]) <= level) { end = headings[i]; break }
|
||||||
|
}
|
||||||
|
const range = doc.createRange()
|
||||||
|
range.setStartBefore(current)
|
||||||
|
if (end) range.setEndBefore(end)
|
||||||
|
else range.setEndAfter(doc.body.lastElementChild || doc.body)
|
||||||
|
const section = range.toString().trim().slice(0, 18000)
|
||||||
|
return { section, outline: outline.slice(0, 7000) }
|
||||||
|
} catch {
|
||||||
|
return { section: '', outline: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToBottom() {
|
||||||
|
await nextTick()
|
||||||
|
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = input.value.trim()
|
||||||
|
if (!text || loading.value || !props.previewGuide) return
|
||||||
|
messages.value.push({ role: 'user', content: text })
|
||||||
|
input.value = ''
|
||||||
|
loading.value = true
|
||||||
|
scrollToBottom()
|
||||||
|
try {
|
||||||
|
const { section, outline } = extractContext()
|
||||||
|
const res = await chatGuide(props.previewGuide.id, {
|
||||||
|
section,
|
||||||
|
outline,
|
||||||
|
messages: messages.value,
|
||||||
|
provider: props.provider,
|
||||||
|
})
|
||||||
|
messages.value.push({ role: 'assistant', content: res.reply || '…' })
|
||||||
|
} catch {
|
||||||
|
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
scrollToBottom()
|
||||||
|
nextTick(() => inputEl.value?.focus())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="detail">
|
||||||
|
<div class="preview" :class="{ landscape: isLandscape }" v-if="previewGuide">
|
||||||
|
<iframe ref="frameEl" :src="htmlUrl(previewGuide.id)" class="preview-frame" :class="{ landscape: isLandscape }" title="Guide-Vorschau" @load="onFrameLoad"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-preview" v-else>
|
||||||
|
<p>Guide-Format anklicken um zu generieren oder Vorschau zu öffnen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="previewGuide && !chatOpen" class="chat-fab" title="Fragen zum Guide" @click="openChat">💬</button>
|
||||||
|
|
||||||
|
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel">
|
||||||
|
<header class="chat-header">
|
||||||
|
<span>Fragen zum Guide</span>
|
||||||
|
<button class="chat-close" title="Chat beenden" @click="closeChat">×</button>
|
||||||
|
</header>
|
||||||
|
<div ref="messagesEl" class="chat-messages">
|
||||||
|
<p v-if="!messages.length" class="chat-hint">Stell eine Frage zum aktuellen Abschnitt.</p>
|
||||||
|
<template v-for="(m, i) in messages" :key="i">
|
||||||
|
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||||
|
<div v-else class="chat-msg user">{{ m.content }}</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="loading" class="chat-msg assistant chat-typing">Denkt…</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input">
|
||||||
|
<textarea
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="input"
|
||||||
|
placeholder="Frage stellen…"
|
||||||
|
@keydown.enter.exact.prevent="send"
|
||||||
|
></textarea>
|
||||||
|
<button :disabled="!input.trim() || loading" @click="send">➤</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail {
|
||||||
|
flex: 1;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview.landscape {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame.landscape {
|
||||||
|
max-width: 1180px;
|
||||||
|
box-shadow: 0 1px 8px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-fab {
|
||||||
|
position: fixed;
|
||||||
|
right: 1.5rem;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 12px var(--shadow);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-fab:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel {
|
||||||
|
position: fixed;
|
||||||
|
right: 1.5rem;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
width: 360px;
|
||||||
|
height: 500px;
|
||||||
|
max-height: calc(100vh - 3rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 24px var(--shadow);
|
||||||
|
z-index: 20;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-close {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-hint {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg {
|
||||||
|
max-width: 85%;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(p) {
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(ul),
|
||||||
|
.chat-msg.markdown :deep(ol) {
|
||||||
|
margin: 0.3em 0;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(li) {
|
||||||
|
margin: 0.15em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(code) {
|
||||||
|
background: var(--border);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(pre) {
|
||||||
|
background: #1e2330;
|
||||||
|
color: #e6e8ee;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.78em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(h1),
|
||||||
|
.chat-msg.markdown :deep(h2),
|
||||||
|
.chat-msg.markdown :deep(h3) {
|
||||||
|
font-size: 0.95em;
|
||||||
|
margin: 0.4em 0 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(a) {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.markdown :deep(th),
|
||||||
|
.chat-msg.markdown :deep(td) {
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
height: 38px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input button {
|
||||||
|
width: 38px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
741
frontend/src/components/TopicSidebar.vue
Normal file
741
frontend/src/components/TopicSidebar.vue
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
topics: { type: Array, required: true },
|
||||||
|
projects: { type: Array, default: () => [] },
|
||||||
|
isProjectSelected: { type: Boolean, default: false },
|
||||||
|
selectedTopic: { type: String, default: null },
|
||||||
|
doneByFormat: { type: Object, default: () => ({}) },
|
||||||
|
latestByFormat: { type: Object, default: () => ({}) },
|
||||||
|
allGuides: { type: Array, default: () => [] },
|
||||||
|
bausteineActive: { type: Boolean, default: false },
|
||||||
|
pinned: { type: Boolean, default: true },
|
||||||
|
dark: { type: Boolean, default: false },
|
||||||
|
provider: { type: String, default: 'claude' },
|
||||||
|
providers: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'preview', 'rework', 'showBausteine', 'addBaustein', 'togglePin', 'sidebarLeave', 'openHelp', 'toggleDark', 'setProvider'])
|
||||||
|
|
||||||
|
function providerAvailable(id) {
|
||||||
|
const p = props.providers.find((x) => x.id === id)
|
||||||
|
return p ? p.available : true
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax' }
|
||||||
|
|
||||||
|
const reindex = ref(false)
|
||||||
|
|
||||||
|
const quickBausteinTitle = ref('')
|
||||||
|
|
||||||
|
function submitQuickAdd() {
|
||||||
|
const title = quickBausteinTitle.value.trim()
|
||||||
|
if (!title) return
|
||||||
|
emit('addBaustein', title)
|
||||||
|
quickBausteinTitle.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formats = [
|
||||||
|
{ key: 'OnePager', label: 'OnePager' },
|
||||||
|
{ key: 'Cheatsheet', label: 'Cheatsheet' },
|
||||||
|
{ key: 'MiniGuide', label: 'MiniGuide' },
|
||||||
|
{ key: 'Guide', label: 'Guide' },
|
||||||
|
{ key: 'EndGuide', label: 'EndGuide' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (props.doneByFormat[format]) return 'done'
|
||||||
|
const latest = props.latestByFormat[format]
|
||||||
|
if (!latest) return 'none'
|
||||||
|
if (latest.status === 'error') return 'none'
|
||||||
|
return latest.status
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMsg(format) {
|
||||||
|
const latest = props.latestByFormat[format]
|
||||||
|
if (latest?.status === 'error') return latest.error_msg || 'Fehler bei der Generierung'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFormatClick(format) {
|
||||||
|
const guide = props.doneByFormat[format]
|
||||||
|
if (guide) {
|
||||||
|
emit('preview', guide)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeInput = ref(null)
|
||||||
|
const inputText = ref('')
|
||||||
|
|
||||||
|
function toggleInput(format) {
|
||||||
|
if (activeInput.value === format) {
|
||||||
|
activeInput.value = null
|
||||||
|
inputText.value = ''
|
||||||
|
} else {
|
||||||
|
activeInput.value = format
|
||||||
|
inputText.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlay(format) {
|
||||||
|
const text = activeInput.value === format ? inputText.value.trim() : ''
|
||||||
|
emit('formatClick', { format, instructions: text, reindex: props.isProjectSelected && reindex.value })
|
||||||
|
activeInput.value = null
|
||||||
|
inputText.value = ''
|
||||||
|
reindex.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh(format) {
|
||||||
|
const guide = props.doneByFormat[format]
|
||||||
|
if (!guide) return
|
||||||
|
const text = activeInput.value === format ? inputText.value.trim() : ''
|
||||||
|
if (!text) return
|
||||||
|
emit('rework', { guideId: guide.id, instructions: text })
|
||||||
|
activeInput.value = null
|
||||||
|
inputText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputEnter(format) {
|
||||||
|
const text = inputText.value.trim()
|
||||||
|
if (props.doneByFormat[format] && text) {
|
||||||
|
handleRefresh(format)
|
||||||
|
} else {
|
||||||
|
handlePlay(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissError(format) {
|
||||||
|
const latest = props.latestByFormat[format]
|
||||||
|
if (latest?.status === 'error') {
|
||||||
|
emit('deleteGuide', latest.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(format) {
|
||||||
|
const guide = props.latestByFormat[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 = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteTopic(topic) {
|
||||||
|
if (!confirm(`Thema "${topic}" und alle zugehörigen Guides löschen?`)) return
|
||||||
|
emit('deleteTopic', topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteProject(name) {
|
||||||
|
if (!confirm(`Projekt "${name}" entfernen?\n\nAchtung: Der Quellordner ./projects/${name} und der Cache werden gelöscht.`)) return
|
||||||
|
emit('deleteProject', name)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="sidebar" @mouseleave="emit('sidebarLeave')">
|
||||||
|
<div class="new-topic">
|
||||||
|
<button
|
||||||
|
class="pin-btn"
|
||||||
|
:title="pinned ? 'Sidebar ausblenden' : 'Sidebar fixieren'"
|
||||||
|
@click="emit('togglePin')"
|
||||||
|
>{{ pinned ? '⇤' : '⇥' }}</button>
|
||||||
|
<button
|
||||||
|
class="help-btn"
|
||||||
|
title="Passendes Thema zu deinem Problem finden"
|
||||||
|
@click="emit('openHelp')"
|
||||||
|
>?</button>
|
||||||
|
<button
|
||||||
|
class="theme-btn"
|
||||||
|
:title="dark ? 'Hellmodus' : 'Dunkelmodus'"
|
||||||
|
@click="emit('toggleDark')"
|
||||||
|
>{{ dark ? '☀' : '🌙' }}</button>
|
||||||
|
<input
|
||||||
|
v-model="newTopic"
|
||||||
|
placeholder="Neues Thema…"
|
||||||
|
@keyup.enter="submit"
|
||||||
|
/>
|
||||||
|
<button @click="submit" :disabled="!newTopic.trim()">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="provider-toggle" v-if="providers.length">
|
||||||
|
<button
|
||||||
|
v-for="p in providers"
|
||||||
|
:key="p.id"
|
||||||
|
:class="{ active: p.id === provider }"
|
||||||
|
:disabled="!p.available"
|
||||||
|
:title="p.available ? '' : 'Nicht konfiguriert (CLI/Key fehlt)'"
|
||||||
|
@click="emit('setProvider', p.id)"
|
||||||
|
>{{ PROVIDER_LABELS[p.id] || p.id }}</button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<label v-if="isProjectSelected" class="reindex-toggle">
|
||||||
|
<input type="checkbox" v-model="reindex" />
|
||||||
|
<span>Projekt neu einlesen</span>
|
||||||
|
</label>
|
||||||
|
<div v-for="f in formats" :key="f.key">
|
||||||
|
<div :class="['format-row', 'fmt-' + guideStatus(f.key)]">
|
||||||
|
<button class="format-name" @click="handleFormatClick(f.key)">
|
||||||
|
<span class="format-label">{{ f.label }}</span>
|
||||||
|
<span
|
||||||
|
v-if="guideStatus(f.key) !== 'none'"
|
||||||
|
class="format-x"
|
||||||
|
@click.stop="handleDelete(f.key)"
|
||||||
|
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen' : 'Löschen'"
|
||||||
|
>×</span>
|
||||||
|
</button>
|
||||||
|
<div class="format-actions">
|
||||||
|
<template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
|
||||||
|
<button class="action-btn play" title="Generieren" @click="handlePlay(f.key)">▶</button>
|
||||||
|
<button
|
||||||
|
class="action-btn pencil"
|
||||||
|
:class="{ active: activeInput === f.key }"
|
||||||
|
title="Anweisungen"
|
||||||
|
@click="toggleInput(f.key)"
|
||||||
|
>✎</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMsg(f.key)" class="format-error">
|
||||||
|
<span class="format-error-text">{{ errorMsg(f.key) }}</span>
|
||||||
|
<button class="format-error-x" title="Fehler entfernen" @click="dismissError(f.key)">×</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeInput === f.key" class="format-input">
|
||||||
|
<input
|
||||||
|
v-model="inputText"
|
||||||
|
:placeholder="doneByFormat[f.key] ? 'Was soll überarbeitet werden?' : 'Anweisungen (optional)…'"
|
||||||
|
@keyup.enter="handleInputEnter(f.key)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="doneByFormat[f.key]"
|
||||||
|
class="action-btn refresh"
|
||||||
|
title="Überarbeiten"
|
||||||
|
:disabled="!inputText.trim()"
|
||||||
|
@click="handleRefresh(f.key)"
|
||||||
|
>↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bausteine-btn-wrapper">
|
||||||
|
<button
|
||||||
|
class="bausteine-btn"
|
||||||
|
:class="{ active: bausteineActive }"
|
||||||
|
@click="emit('showBausteine')"
|
||||||
|
>Bausteine</button>
|
||||||
|
<div class="quick-add">
|
||||||
|
<input
|
||||||
|
v-model="quickBausteinTitle"
|
||||||
|
placeholder="Neuer Baustein…"
|
||||||
|
@keyup.enter="submitQuickAdd"
|
||||||
|
/>
|
||||||
|
<button @click="submitQuickAdd" :disabled="!quickBausteinTitle.trim()">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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="confirmDeleteTopic(t)" title="Löschen">×</button>
|
||||||
|
</li>
|
||||||
|
<template v-if="projects.length">
|
||||||
|
<li class="projects-divider">Projekte</li>
|
||||||
|
<li
|
||||||
|
v-for="p in projects"
|
||||||
|
:key="'project-' + p"
|
||||||
|
:class="{ active: p === selectedTopic, 'project-item': true }"
|
||||||
|
@click="emit('select', p)"
|
||||||
|
>
|
||||||
|
<span>{{ p }}</span>
|
||||||
|
<button class="delete-topic" @click.stop="confirmDeleteProject(p)" title="Projekt entfernen">×</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
width: 300px;
|
||||||
|
min-width: 300px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic .pin-btn {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic .pin-btn:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic .help-btn {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 6px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic .help-btn:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic .theme-btn {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-topic .theme-btn:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.5rem 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle button:first-child {
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle button:last-child {
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-list {
|
||||||
|
list-style: none;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-list li {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text);
|
||||||
|
transition: background 0.15s;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-list li:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-list li.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-topic {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-list li:hover .delete-topic {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-divider {
|
||||||
|
cursor: default;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.6rem 1rem 0.3rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-divider:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-list li.project-item span::before {
|
||||||
|
content: '📁 ';
|
||||||
|
}
|
||||||
|
|
||||||
|
.reindex-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reindex-toggle input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Format section */
|
||||||
|
.format-section {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
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: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-name {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: default;
|
||||||
|
color: var(--text-faint);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-x {
|
||||||
|
display: none;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-name:hover .format-x {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmt-done .format-name {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--success-soft);
|
||||||
|
border: 1px solid var(--success-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmt-done .format-name:hover {
|
||||||
|
background: var(--success-soft-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmt-generating .format-name,
|
||||||
|
.fmt-queued .format-name {
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 0.75rem 6px calc(0.75rem + 8px);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--danger);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-error-text {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-error-x {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-error-x:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.play:hover {
|
||||||
|
background: var(--success-soft);
|
||||||
|
border-color: var(--success-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.refresh {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.refresh:hover {
|
||||||
|
background: var(--success-soft);
|
||||||
|
border-color: var(--success-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.pencil {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.pencil:hover,
|
||||||
|
.action-btn.pencil.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 0.75rem 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-input input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-input input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled:hover {
|
||||||
|
background: none;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.65; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bausteine-btn-wrapper {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bausteine-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bausteine-btn:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bausteine-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
152
templates/Format/Baustein.md
Normal file
152
templates/Format/Baustein.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
```
|
||||||
|
BAUSTEIN CARD STYLE (HTML+CSS, viewable in a browser)
|
||||||
|
|
||||||
|
PURPOSE
|
||||||
|
- Single building block ("Baustein") as a compact card
|
||||||
|
- Quick lookup of individual concepts
|
||||||
|
- The card is part of a larger collection of building blocks
|
||||||
|
- Not for learning, but for recognition
|
||||||
|
- Exactly 1 building block per card
|
||||||
|
|
||||||
|
FORMAT
|
||||||
|
- HTML with embedded CSS
|
||||||
|
- Viewable in a browser (no PDF output)
|
||||||
|
- Card centered on a light gray background
|
||||||
|
- Max-width 1000px, padding 28px 36px
|
||||||
|
- Border-radius 12px, subtle shadow
|
||||||
|
- Page background #f0f0f5
|
||||||
|
|
||||||
|
STRUCTURE — STRICTLY ONLY THESE 4 ELEMENTS
|
||||||
|
1. Title (h1, bold, no label, no logo)
|
||||||
|
2. Description (one concise sentence)
|
||||||
|
3. Purpose (italic, gray, one concise sentence)
|
||||||
|
4. Code example (the single most relevant one, with a mini label on top)
|
||||||
|
|
||||||
|
NO logo, NO "Baustein" label, NO language tag, NO section headings
|
||||||
|
("Beschreibung", "Zweck", "Relevante Beispiele"), NO info blocks,
|
||||||
|
NO warn/tip/note boxes, NO meta information, NO divider lines between
|
||||||
|
sections.
|
||||||
|
|
||||||
|
TYPOGRAPHY
|
||||||
|
- Body: -apple-system, "Segoe UI", sans-serif, 14px, line-height 1.5
|
||||||
|
- Title h1: 28px, font-weight 800, letter-spacing -0.5px, line-height 1.1
|
||||||
|
- Description: 14px, line-height 1.55
|
||||||
|
- Purpose: 14px, italic, color #5a6470
|
||||||
|
- Code: "SF Mono", Consolas, monospace, 12px, line-height 1.5
|
||||||
|
- Code label: 10px, letter-spacing 1px
|
||||||
|
|
||||||
|
COLORS
|
||||||
|
- Card background: #ffffff
|
||||||
|
- Page background: #f0f0f5
|
||||||
|
- Text: #1a1a1a
|
||||||
|
- Muted (purpose): #5a6470
|
||||||
|
- Code background: #1e2a3a
|
||||||
|
- Code text: #e6e6e6
|
||||||
|
- Syntax highlighting:
|
||||||
|
- Keywords (.k): #ff79c6 (pink)
|
||||||
|
- Variables (.v): #ffb86c (orange)
|
||||||
|
- Strings (.s): #f1c40f (yellow)
|
||||||
|
- Functions (.f): #50fa7b (green)
|
||||||
|
- Types (.t): #8be9fd (cyan)
|
||||||
|
- Comments (.c): #6b8aae italic
|
||||||
|
- Label color in code: #8be9fd (cyan)
|
||||||
|
|
||||||
|
CONTENT PRINCIPLES
|
||||||
|
- Title: the MOST COMMON short term, max 1-2 words, no prefix, no variant
|
||||||
|
- Good: "Header", "Variable", "Klasse", "for-Schleife"
|
||||||
|
- Bad: "Überschriften h1–h6", "Variablen-Deklaration mit Typ"
|
||||||
|
- Description: WHAT does it do mechanically? 1 sentence, max 5 words,
|
||||||
|
abstract/technical
|
||||||
|
- Good: "Definiert eine Überschrift.", "Wiederholt einen Code-Block."
|
||||||
|
- Bad: "Sechs Ebenen von der wichtigsten bis zur untergeordneten Überschrift."
|
||||||
|
- Purpose: WHEN do I need this? 1 sentence, max 5 words,
|
||||||
|
situational/problem trigger
|
||||||
|
- Good: "Strukturiert die Seiteninhalte.", "Liste oder Bereich durchgehen."
|
||||||
|
- Bad: "Gliedert Inhalt in Hierarchie für Leser, SEO und Screenreader-Navigation."
|
||||||
|
- IMPORTANT: description and purpose must NOT overlap in content
|
||||||
|
- Bad (tautological): description "Wiederholt Code." / purpose "Etwas mehrfach tun."
|
||||||
|
- Good (distinct): description "Wiederholt einen Code-Block." / purpose "Liste oder Bereich durchgehen."
|
||||||
|
- Both sentences: cut every superfluous word, no enumerations, no comma lists
|
||||||
|
- Exactly ONE code example: the most relevant / most common / most typical
|
||||||
|
- The example shows the standard use case, not edge cases
|
||||||
|
- Label above the example: max 2-3 words, simple, descriptive
|
||||||
|
- Good: "Alle Header", "Zuweisung", "Grund-Regel"
|
||||||
|
- Bad: "Alle Ebenen h1–h6", "Variable mit Typ-Hint deklarieren"
|
||||||
|
|
||||||
|
LAYOUT DETAILS
|
||||||
|
- Header and body are not separate sections, everything in one card
|
||||||
|
- Title first, directly below it the description, below that the purpose
|
||||||
|
(with margin-bottom)
|
||||||
|
- Margin-bottom title: 14px
|
||||||
|
- Margin-bottom description: 6px (close to the purpose)
|
||||||
|
- Margin-bottom purpose: 22px (distance to the code)
|
||||||
|
- Code block padding: 14px 16px, border-radius 8px
|
||||||
|
|
||||||
|
CODE EXAMPLE
|
||||||
|
- EXACTLY ONE example per card
|
||||||
|
- The most relevant, most typical, most common one — not edge cases
|
||||||
|
- Very short: ideally 3-6 lines, max 8 lines
|
||||||
|
- With a short label on top (2-4 words)
|
||||||
|
- Syntax highlighting via span classes (.k, .v, .s, etc.)
|
||||||
|
|
||||||
|
HTML ENTITIES IN CODE (MANDATORY for HTML/XML/JSX/Vue/JSX-like code)
|
||||||
|
- If the code example ITSELF shows HTML, XML, JSX, or similar tag syntax,
|
||||||
|
angle brackets MUST be written as HTML entities:
|
||||||
|
- `<` → `<`
|
||||||
|
- `>` → `>`
|
||||||
|
- `&` → `&`
|
||||||
|
- Reason: the code is rendered via v-html in the browser. Raw `<h1>` would
|
||||||
|
otherwise be interpreted as a real DOM element and disappear.
|
||||||
|
- Good: `<span class="t"><h1></span>Text<span class="t"></h1></span>`
|
||||||
|
- Bad: `<span class="t"><h1></span>Text<span class="t"></h1></span>`
|
||||||
|
- Bad: `<h1>Text</h1>` (entirely without spans and without entities)
|
||||||
|
- This rule applies ONLY to the contents of the code example, NOT to the
|
||||||
|
`<span class="...">` wrappers themselves
|
||||||
|
|
||||||
|
CONCRETE EXAMPLE — building block "Header" (HTML)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Header",
|
||||||
|
"description": "Definiert eine Überschrift.",
|
||||||
|
"purpose": "Strukturiert die Seiteninhalte.",
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"label": "Alle Header",
|
||||||
|
"code": "<span class=\"t\"><h1></span>Hauptüberschrift<span class=\"t\"></h1></span>\n<span class=\"t\"><h2></span>Kapitel<span class=\"t\"></h2></span>\n<span class=\"t\"><h3></span>Unterabschnitt<span class=\"t\"></h3></span>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
AVOID
|
||||||
|
- Long explanatory texts
|
||||||
|
- Multiple sentences for description or purpose
|
||||||
|
- Performance tips, trade-offs, edge cases
|
||||||
|
- Related building blocks, variants, anti-patterns
|
||||||
|
- Tables, lists, enumerations
|
||||||
|
- Icons, emojis, symbols
|
||||||
|
- Complex code examples (over 8 lines)
|
||||||
|
- Multiple examples (variants, alternatives)
|
||||||
|
|
||||||
|
TOPIC-SPECIFIC ADJUSTMENTS
|
||||||
|
- For other languages: adapt syntax highlighting classes
|
||||||
|
- Title size and spacing stay the same
|
||||||
|
- Card layout stays the same
|
||||||
|
- Content (description, purpose, example) is language-specific
|
||||||
|
|
||||||
|
GENERATION WITH FEEDBACK LOOP
|
||||||
|
1. Write the HTML
|
||||||
|
2. View in a browser (Playwright screenshot or directly)
|
||||||
|
3. Check:
|
||||||
|
- Really only 4 elements (title, description, purpose, example)?
|
||||||
|
- Description and purpose each max 5 words?
|
||||||
|
- Title max 1-2 words, common short term?
|
||||||
|
- Label max 2-3 words, simple?
|
||||||
|
- Code example under 8 lines?
|
||||||
|
- Label above the code block short and to the point?
|
||||||
|
- Card compact, no empty space?
|
||||||
|
- Is the chosen example really the most typical one?
|
||||||
|
- For HTML/XML/JSX code: all `<` and `>` written as `<` and `>`?
|
||||||
|
4. If something is too much: remove it, don't add
|
||||||
|
5. On every iteration check: can anything else be removed?
|
||||||
|
```
|
||||||
123
templates/Format/Cheatsheet.md
Normal file
123
templates/Format/Cheatsheet.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
```
|
||||||
|
CHEATSHEET STYLE LANDSCAPE (HTML/CSS → PDF via WeasyPrint)
|
||||||
|
|
||||||
|
FORMAT
|
||||||
|
- A4 landscape (297mm × 210mm)
|
||||||
|
- @page { size: A4 landscape; margin: 0; }
|
||||||
|
- Padding: 7mm 9mm 18mm 9mm (room for the footer at the bottom)
|
||||||
|
- Position relative for the absolute footer
|
||||||
|
|
||||||
|
LAYOUT
|
||||||
|
- Grid: hero (auto) / main (4 columns) / footer (absolute)
|
||||||
|
- Main: 4 equal-width columns, gap 3mm
|
||||||
|
- Columns internally: flex-column, gap 3mm
|
||||||
|
- 8-12 thematic blocks distributed (2-3 per column)
|
||||||
|
|
||||||
|
STRUCTURE (in this order)
|
||||||
|
1. Hero: logo left (15mm), title + subtitle center, version + date right
|
||||||
|
2. Main grid: 8-12 blocks in 4 columns
|
||||||
|
3. Footer: colored box with quick commands + tag (absolute, bottom)
|
||||||
|
|
||||||
|
DIFFERENCES FROM THE PORTRAIT CHEATSHEET
|
||||||
|
- 4 columns instead of 3 (more horizontal space)
|
||||||
|
- 9-12 blocks instead of 8-10
|
||||||
|
- Footer with 4 quick commands instead of 3
|
||||||
|
- Smaller font sizes (8pt body instead of 8.5pt)
|
||||||
|
- More compact code blocks (6.5pt instead of 6.8pt)
|
||||||
|
|
||||||
|
BLOCK STRUCTURE
|
||||||
|
- Block head: accent-color background, white text, icon left (3.5mm)
|
||||||
|
- Block body: white background, thin border, rounded 2mm
|
||||||
|
- Content: dense reference, not explanation
|
||||||
|
- Variants: reference table, code block, tile grid, plus/minus
|
||||||
|
|
||||||
|
COLORS (max 3 + neutrals)
|
||||||
|
- Accent color: lean on the topic's official color
|
||||||
|
- Accent dark: darker variant for headings
|
||||||
|
- Accent darker: even darker for the footer
|
||||||
|
- Accent color for plus/minus: green/red
|
||||||
|
- Background soft: light variant of the accent color
|
||||||
|
- Code background: #1e2a3a (dark)
|
||||||
|
- Text: #1a1a1a / muted #5a6470 / line #d8dde3
|
||||||
|
|
||||||
|
TYPOGRAPHY
|
||||||
|
- Body: 8pt, line-height 1.35
|
||||||
|
- Hero h1: 18pt bold
|
||||||
|
- Block head: 8pt bold uppercase, letter-spacing 0.5pt
|
||||||
|
- Tables: 7.5pt, keys 7pt monospace
|
||||||
|
- Code: 6.5pt monospace, line-height 1.4
|
||||||
|
- Inline code: 7pt monospace, accent color
|
||||||
|
- Max 3 font sizes per block
|
||||||
|
|
||||||
|
ICONS
|
||||||
|
- SVG inline, stroke instead of fill
|
||||||
|
- 3.5mm in block heads
|
||||||
|
- 2mm in tile icons (smaller than portrait)
|
||||||
|
- currentColor for automatic adaptation
|
||||||
|
|
||||||
|
BLOCK TYPES (use for variation)
|
||||||
|
- Reference table: 2 columns (command/method → meaning)
|
||||||
|
- Code block: complete example with syntax highlighting
|
||||||
|
- Tile grid: 2x4 with icons (e.g. ecosystem)
|
||||||
|
- Directive grid: 2 columns, compact terms + short description
|
||||||
|
- Plus/minus split: 2 columns (idiomatic vs avoid)
|
||||||
|
|
||||||
|
TOPIC-SPECIFIC ADJUSTMENTS (choose before generating)
|
||||||
|
- Accent color: the topic's official color
|
||||||
|
- Logo letter(s) or abbreviation
|
||||||
|
- Version + as-of date
|
||||||
|
- Block selection: the 8-12 most important reference topics
|
||||||
|
- Quick commands in the footer: the 4 most important commands
|
||||||
|
|
||||||
|
REQUIRED VISUAL ELEMENTS
|
||||||
|
- At least 1 code block (often several in landscape)
|
||||||
|
- At least 1 tile grid with icons
|
||||||
|
- At least 1 plus/minus split
|
||||||
|
- Footer with quick commands
|
||||||
|
- Version badge in the hero
|
||||||
|
|
||||||
|
AVOID
|
||||||
|
- Plain bullet lists without structure
|
||||||
|
- Explanatory text (belongs in a guide, not a cheatsheet)
|
||||||
|
- More than 12 blocks (overcrowded)
|
||||||
|
- More than 3 font sizes
|
||||||
|
- Vertically very long blocks (unbalanced columns)
|
||||||
|
- Floats or absolute positioning (except for the footer)
|
||||||
|
|
||||||
|
GENERATION WITH FEEDBACK LOOP (max 3 iterations)
|
||||||
|
1. Write the HTML
|
||||||
|
2. weasyprint file.html file.pdf
|
||||||
|
3. PDF to PNG: python -c "from pdf2image import convert_from_path; convert_from_path('file.pdf', dpi=120)[0].save('preview.png')"
|
||||||
|
4. View the preview with the Read tool
|
||||||
|
5. Check:
|
||||||
|
- Footer does not overlap the content?
|
||||||
|
- 4 columns balanced (similar height)?
|
||||||
|
- All blocks fully visible?
|
||||||
|
- Code blocks not cut off?
|
||||||
|
- Icons render?
|
||||||
|
6. On problems: tighten the content or increase padding-bottom, repeat from step 2
|
||||||
|
7. Output after max 3 iterations
|
||||||
|
|
||||||
|
INSTALLATION
|
||||||
|
- pip install weasyprint pdf2image
|
||||||
|
- apt install poppler-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
DARK MODE (REQUIRED)
|
||||||
|
- Define all colors exclusively via :root variables
|
||||||
|
(--ink, --muted, --line, --bg-soft, accent variables).
|
||||||
|
- Additionally ship a dark-mode block:
|
||||||
|
@media screen {
|
||||||
|
html[data-theme="dark"] { ...dark variable values... }
|
||||||
|
html[data-theme="dark"] body { background: #15171c; }
|
||||||
|
}
|
||||||
|
- The app enables it via data-theme="dark" on <html>.
|
||||||
|
Do NOT use prefers-color-scheme.
|
||||||
|
- Dark values: dark backgrounds (#15171c / #23262e), light text (#e6e8ee),
|
||||||
|
muted lines (#2c3038). Lift accent colors so headings and links stay
|
||||||
|
readable on a dark background (check contrast). Elements with light text on
|
||||||
|
accent backgrounds (e.g. table headers) may keep their light background
|
||||||
|
color.
|
||||||
|
- Do not hardcode callout/infobox backgrounds — or darken them explicitly in
|
||||||
|
dark mode (e.g. to var(--bg-soft)); the colored border stays.
|
||||||
|
- Only inside @media screen — print/PDF stays light.
|
||||||
190
templates/Format/EndGuide.md
Normal file
190
templates/Format/EndGuide.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
END-GUIDE STYLE (HTML/CSS → PDF via WeasyPrint)
|
||||||
|
|
||||||
|
FORMAT
|
||||||
|
- A4 portrait, 50–250 pages (topic-dependent — small topic short,
|
||||||
|
large topic long; the page count follows from the inventory)
|
||||||
|
- @page { size: A4; margin: 18mm; }
|
||||||
|
- Cover page + automatic table of contents
|
||||||
|
- Footer: page number center, "<Topic> EndGuide" right
|
||||||
|
|
||||||
|
COVERAGE (highest guide tier)
|
||||||
|
- ALL building blocks of the topic — complete, "rather too much than too little".
|
||||||
|
- From the very first line up to expert topics (architecture, internals,
|
||||||
|
operations — whatever the expert tier is for the topic).
|
||||||
|
- Name the current version of the topic as the target version.
|
||||||
|
|
||||||
|
TOPIC INVENTORY (binding)
|
||||||
|
- The inventory is produced beforehand by a research agent and supplied with
|
||||||
|
the task. It is the binding list of material: 1 inventory item = 1 OWN H2
|
||||||
|
section following the pattern Title·Explanation·Example.
|
||||||
|
- Do NOT merge, trim, or condense items while writing — if the workload feels
|
||||||
|
too large, work part by part instead of compressing.
|
||||||
|
- Derive parts and chapters from the inventory: group related items into
|
||||||
|
chapters, chapters into parts. There is NO default number of parts/chapters.
|
||||||
|
|
||||||
|
TARGET AUDIENCE — FROM BEGINNER TO EXPERT
|
||||||
|
- Starts at zero, progresses step by step across the parts
|
||||||
|
- Early parts explain every term; later ones assume what came before
|
||||||
|
- Forward references ("own part") are allowed — the guide is read cover to
|
||||||
|
cover or used for lookup
|
||||||
|
|
||||||
|
CORE PRINCIPLE — TITLE · EXPLANATION · EXAMPLE (the defining pattern)
|
||||||
|
- EVERY concept in exactly this order:
|
||||||
|
1. Title: pure concept/verb/type name. Nothing else.
|
||||||
|
2. Explanation: max. 3 short main clauses, simply worded, no nested
|
||||||
|
sentences. Names the functions/building blocks and what they do.
|
||||||
|
Recommendations, real-world problems, and warnings do NOT belong in the
|
||||||
|
explanation — that is what callouts (tip/warn/note) are for.
|
||||||
|
3. Example: runnable code (or a concrete example) with the visible result
|
||||||
|
as a comment.
|
||||||
|
- The heading NEVER carries the function list or the definition:
|
||||||
|
- bad: "Ersetzen: str_replace & substr_replace"
|
||||||
|
"object – Instanz einer Klasse"
|
||||||
|
- good: "Ersetzen" + explanation names the functions + example
|
||||||
|
"object" + explanation "Instanz einer Klasse" + example
|
||||||
|
|
||||||
|
CONTENT PRINCIPLES
|
||||||
|
- Completeness: enumerable sets ALWAYS complete (all operators, all variants,
|
||||||
|
all modes …). Only very large sets (e.g. hundreds of format characters) as
|
||||||
|
a selection + reference to the docs.
|
||||||
|
- Show the mechanics: examples are real, runnable mini-demos WITH output —
|
||||||
|
not just signatures or empty interfaces. For patterns/interfaces:
|
||||||
|
contract + concrete implementation + call with visible result.
|
||||||
|
- Depth before enumeration: explain why/when first, then show.
|
||||||
|
- No performance numbers without a source.
|
||||||
|
|
||||||
|
CODE CONVENTIONS (for technical topics)
|
||||||
|
- Identifiers in English: variables, functions, classes, methods, constants.
|
||||||
|
- German stays: prose, headings, code comments, output strings,
|
||||||
|
sample data ("Anna", "Köln", "Hallo Welt").
|
||||||
|
- Unchanged: topic-/language-native names (built-ins, standard APIs);
|
||||||
|
table/column names in strings (= data, not identifiers).
|
||||||
|
- Result comment on every example: // 19.99 €
|
||||||
|
- Neutral data: names Anna/Ben/Tom, places Köln/Hamburg, mail@example.com.
|
||||||
|
No personal or real data.
|
||||||
|
- Prefer modern syntax of the current target version; mark version-dependent
|
||||||
|
features ("seit Version X").
|
||||||
|
|
||||||
|
STRUCTURE
|
||||||
|
1. Cover: logo, title "<Topic> — Der EndGuide", subtitle, badge
|
||||||
|
"<N> Teile · <M> Kapitel" (numbers taken from the inventory)
|
||||||
|
2. Table of contents (generated automatically from the structure)
|
||||||
|
3. Per part: divider page (part number · title · description · chapter list)
|
||||||
|
4. Per chapter: chapter number + title, short lead sentence, then H2 sections
|
||||||
|
following the pattern Title·Explanation·Example
|
||||||
|
|
||||||
|
NUMBERING
|
||||||
|
- Parts and chapters are SEPARATE counters.
|
||||||
|
- Chapters run through the whole guide — they do NOT restart per part.
|
||||||
|
- "Teil 7" ≠ "Kapitel 7".
|
||||||
|
|
||||||
|
ELEMENTS
|
||||||
|
- Body text: justify; explanations as prose, bullet lists sparingly.
|
||||||
|
- Code blocks: dark background, syntax highlighting, with result comments.
|
||||||
|
- Inline code: light background, accent color — for every technical term and
|
||||||
|
function name in prose.
|
||||||
|
- Callouts in 3 flavors: tip (✓), warn (!), note (i). Sparingly, at most
|
||||||
|
one per section.
|
||||||
|
- NO overview or reference tables. Every function/feature gets
|
||||||
|
explanation + example inline.
|
||||||
|
|
||||||
|
TYPOGRAPHY & COLORS (max 3 + neutrals)
|
||||||
|
- Accent color: lean on the topic's official color; dark code blocks
|
||||||
|
(#1e2a3a family).
|
||||||
|
- Body serif; headings sans-serif bold; code monospace.
|
||||||
|
- max. 3 font sizes per section.
|
||||||
|
|
||||||
|
CALLOUT USAGE
|
||||||
|
- tip (✓): best practice / recommendation.
|
||||||
|
- warn (!): security issue or pitfall.
|
||||||
|
- note (i): background / distinction.
|
||||||
|
- MANDATORY: sentences like "Empfehlung: …", "In der Praxis problematisch: …",
|
||||||
|
"Achtung: …" ALWAYS go into the matching callout, never into the running
|
||||||
|
prose of the explanation. This keeps the explanation short and the hints
|
||||||
|
scannable.
|
||||||
|
|
||||||
|
BUILD ARCHITECTURE (structure-driven)
|
||||||
|
- One source of truth: STRUCTURE in build.py = list of
|
||||||
|
(part kicker, title, description, [chapter titles]) — derived directly from
|
||||||
|
the inventory. Order, numbers, table of contents, and divider pages are
|
||||||
|
generated from it automatically.
|
||||||
|
- Content in modules content_partN.py: CHAPTERS = { "chapter title": body_html }.
|
||||||
|
Write part by part — never the whole guide in one go. After each part:
|
||||||
|
count the H2 sections against that part's inventory items, add anything
|
||||||
|
missing immediately, only then start the next part.
|
||||||
|
- build.py reports the total number of H2 sections at the end; it must match
|
||||||
|
the number of inventory items.
|
||||||
|
- Highlight span classes in code:
|
||||||
|
c=comment, k=keyword, s=string/number, f=function,
|
||||||
|
t=type/class/constant, v=variable, a=attribute.
|
||||||
|
Inline code in prose: <code class="inline">.
|
||||||
|
- Escaping in code: < > & as < > & (e.g. ->, =>, &&).
|
||||||
|
- scrub() filters out personal leftovers (names/places/emails) during build.
|
||||||
|
|
||||||
|
AVOID
|
||||||
|
- Fixed part/chapter/page counts as a target (scope follows from the
|
||||||
|
inventory, not the other way around).
|
||||||
|
- Merging or dropping inventory items.
|
||||||
|
- Function list or definition in the heading (the title stays pure).
|
||||||
|
- Explanations longer than 3 sentences or with nested sentences.
|
||||||
|
- Recommendations/warnings in running prose instead of in a callout.
|
||||||
|
- Overview/reference tables without explanatory text.
|
||||||
|
- German code identifiers.
|
||||||
|
- Personal or real data.
|
||||||
|
- Incomplete enumerable sets.
|
||||||
|
- Pure interface/signature stubs without call + output.
|
||||||
|
- Performance numbers without a source.
|
||||||
|
- Code block or callout torn across a page break
|
||||||
|
(page-break-inside: avoid).
|
||||||
|
|
||||||
|
GENERATION & VERIFICATION
|
||||||
|
1. Read the inventory, derive STRUCTURE from it.
|
||||||
|
2. python3 build.py → guide.html (reports chapters/parts + sections)
|
||||||
|
3. weasyprint guide.html guide.pdf
|
||||||
|
4. Verify — the target values come from the inventory:
|
||||||
|
- grep -c 'toc-part' == number of parts in STRUCTURE
|
||||||
|
- grep -c 'chapter-num' == number of chapters in STRUCTURE
|
||||||
|
- grep -c '<h2' == number of inventory items (1 item = 1 section;
|
||||||
|
on mismatch: write out the missing items — do NOT trim the inventory)
|
||||||
|
- grep -c '<table>' == 0
|
||||||
|
- no real names/places/emails
|
||||||
|
- page count (pdfinfo) within the 50–250 range
|
||||||
|
5. Render samples (pdftoppm) and inspect:
|
||||||
|
- Heading = pure title, explanation below it, then the example?
|
||||||
|
- Code in English, explanation and comments in German?
|
||||||
|
- Code blocks/callouts not torn across page breaks?
|
||||||
|
- Cover, table of contents, and part dividers correct?
|
||||||
|
|
||||||
|
CHECKLIST PER SECTION
|
||||||
|
- [ ] Heading is a pure title (no function list, no definition)
|
||||||
|
- [ ] Explanation in prose present, names the building blocks
|
||||||
|
- [ ] Explanation max. 3 short main clauses, no nested sentences
|
||||||
|
- [ ] Recommendations/warnings as callout, not in running prose
|
||||||
|
- [ ] runnable example with result comment
|
||||||
|
- [ ] no table
|
||||||
|
- [ ] English identifiers, German explanation/comments/data
|
||||||
|
- [ ] enumerable set complete
|
||||||
|
- [ ] neutral sample data
|
||||||
|
|
||||||
|
INSTALLATION
|
||||||
|
- pip install weasyprint
|
||||||
|
- apt install poppler-utils (pdfinfo, pdftoppm, pdftotext)
|
||||||
|
|
||||||
|
DARK MODE (REQUIRED)
|
||||||
|
- Define all colors exclusively via :root variables
|
||||||
|
(--ink, --muted, --line, --bg-soft, accent variables).
|
||||||
|
- Additionally ship a dark-mode block:
|
||||||
|
@media screen {
|
||||||
|
html[data-theme="dark"] { ...dark variable values... }
|
||||||
|
html[data-theme="dark"] body { background: #15171c; }
|
||||||
|
}
|
||||||
|
- The app enables it via data-theme="dark" on <html>.
|
||||||
|
Do NOT use prefers-color-scheme.
|
||||||
|
- Dark values: dark backgrounds (#15171c / #23262e), light text (#e6e8ee),
|
||||||
|
muted lines (#2c3038). Lift accent colors so headings and links stay
|
||||||
|
readable on a dark background (check contrast). Elements with light text on
|
||||||
|
accent backgrounds (e.g. table headers) may keep their light background
|
||||||
|
color.
|
||||||
|
- Do not hardcode callout/infobox backgrounds — or darken them explicitly in
|
||||||
|
dark mode (e.g. to var(--bg-soft)); the colored border stays.
|
||||||
|
- Only inside @media screen — print/PDF stays light.
|
||||||
782
templates/Format/Guide.md
Normal file
782
templates/Format/Guide.md
Normal file
@@ -0,0 +1,782 @@
|
|||||||
|
# Guide Style System — Authoring & Build Specification
|
||||||
|
|
||||||
|
This document is a **complete, self-contained specification** for producing a polished, book-style guide as a single HTML file that renders to a clean A4 PDF. It is **topic-neutral**: use it for programming subjects (PHP, Godot, Blender) and equally for non-technical ones (nutrition, finance, psychology, communication, language learning, …).
|
||||||
|
|
||||||
|
You will normally be given two things: this specification and possibly one reference HTML file built with it. From those alone you must be able to:
|
||||||
|
|
||||||
|
1. Gather the subject knowledge yourself (research as needed).
|
||||||
|
2. Decide a structure (parts → chapters).
|
||||||
|
3. Write a single HTML file that embeds the CSS from this document verbatim.
|
||||||
|
4. Convert that HTML to PDF.
|
||||||
|
|
||||||
|
Follow this spec exactly. The visual identity depends on small details (spacing, weights, the single accent color), so do not improvise the CSS. You **do** have full freedom over content, structure, length, and which optional building blocks you use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Output contract
|
||||||
|
|
||||||
|
- **One HTML file**, self-contained: a single `<style>` block in `<head>`, no external CSS, no external JS, no web fonts. Fonts are system fonts only (see §3).
|
||||||
|
- The HTML converts to PDF with **WeasyPrint**. The canonical build command is:
|
||||||
|
```
|
||||||
|
weasyprint guide.html guide.pdf
|
||||||
|
```
|
||||||
|
- Page size is **A4**. All page furniture (page numbers, running header, footer label) is produced by CSS `@page` rules — you never write headers/footers into the body.
|
||||||
|
- The body is laid out as: **Cover → Table of Contents → Part 1 divider → its chapters → Part 2 divider → its chapters → …**
|
||||||
|
|
||||||
|
If WeasyPrint is unavailable, the HTML must still be a valid, good-looking standalone document; the layout is built so it degrades gracefully in a browser too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Mental model of a guide
|
||||||
|
|
||||||
|
A guide is a short book. The structure is always the same three levels:
|
||||||
|
|
||||||
|
- **Part** — a major thematic section (e.g. "Fundamentals", "Advanced", "Pitfalls"). Each part gets a full-page **divider**. A guide typically has **3–8 parts**.
|
||||||
|
- **Chapter** — one focused topic. Chapters flow continuously down the page (they do not force a page break); each is separated from the previous by spacing and its own heading block. **3–10 chapters per part**. Each chapter is numbered sequentially across the whole guide (Chapter 1, 2, 3 … regardless of part).
|
||||||
|
- **Section** (`<h2>`) and **sub-section** (`<h3>`) — structure inside a chapter.
|
||||||
|
|
||||||
|
### Scope — coverage-driven (middle tier)
|
||||||
|
|
||||||
|
**10–30 pages, covering all important building blocks of the topic** — everything a practical user needs in real work. More selective than the EndGuide tier ("all building blocks"), far broader than the MiniGuide tier ("core building blocks only"). How much that is depends on the topic.
|
||||||
|
|
||||||
|
A **topic inventory** is produced beforehand by a research agent and supplied to you. It is binding: every inventory item must appear in the guide as its own section — do not merge, trim, or drop items. Derive parts and chapters from the inventory; part/chapter counts follow from it, there is no default number. Page count is an outcome within the 10–30 range, never a target. State the resulting scope on the cover (parts · chapters).
|
||||||
|
|
||||||
|
### Voice and content rules (apply to every topic)
|
||||||
|
|
||||||
|
- Write in the **reader's language** (German request → German guide). This spec is in English, but that does not constrain the output language.
|
||||||
|
- Open each chapter with a one- or two-sentence **lead** (`.lead`) that frames why the topic matters.
|
||||||
|
- Explain each new term the first time it appears. Assume an intelligent reader who is new to *this* subject.
|
||||||
|
- Prefer **short, concrete examples** over abstract description. For technical topics that means small code snippets; for non-technical topics it means worked examples, sample dialogues, before/after comparisons, small tables, checklists, or step lists.
|
||||||
|
- **Explanations are short and simple**: max. 3 short main clauses per concept, no nested sentences. Plain language over precision-flexing.
|
||||||
|
- Use **callouts** to highlight tips, warnings, side-notes, and deeper digressions — not more than one or two per chapter. Sentences like "Empfehlung: …", "In der Praxis problematisch: …", "Achtung: …" ALWAYS go into the matching callout (tip/warn/note), never into running explanation text.
|
||||||
|
- Keep prose tight. This is a reference people skim and return to, not an essay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The CSS — embed this verbatim
|
||||||
|
|
||||||
|
Paste the following into a single `<style>` element in `<head>`. **Do not rename classes, change spacing units, or restructure rules.** The only thing you customize is the small block of CSS variables in `:root` (see §4).
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ============================================================
|
||||||
|
GUIDE STYLE SYSTEM · Stylesheet (WeasyPrint)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- PAGE ---------- */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm 18mm 18mm 18mm;
|
||||||
|
@bottom-center {
|
||||||
|
content: counter(page);
|
||||||
|
font-family: -apple-system, "Segoe UI", sans-serif;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
@bottom-right {
|
||||||
|
content: var(--footer-label); /* set in :root, see §4 */
|
||||||
|
font-family: -apple-system, "Segoe UI", sans-serif;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
@top-right {
|
||||||
|
content: string(chaptertitle);
|
||||||
|
font-family: -apple-system, "Segoe UI", sans-serif;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover page: no header/footer */
|
||||||
|
@page cover {
|
||||||
|
margin: 0;
|
||||||
|
@bottom-center { content: none; }
|
||||||
|
@bottom-right { content: none; }
|
||||||
|
@top-right { content: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Part-divider pages: no running header (the title is already prominent) */
|
||||||
|
@page chapterstart {
|
||||||
|
@top-right { content: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ===== CUSTOMIZE PER TOPIC — see §4 ===== */
|
||||||
|
--accent: #777BB4; /* main accent */
|
||||||
|
--accent-dark: #4F5B93; /* darker shade for headings */
|
||||||
|
--accent-darker: #2C3E66; /* darkest, for cover gradient + part titles */
|
||||||
|
--footer-label: "Guide"; /* short title shown bottom-right of every page */
|
||||||
|
|
||||||
|
/* ===== USUALLY LEAVE THESE ALONE ===== */
|
||||||
|
--ink: #1a1a1a;
|
||||||
|
--muted: #5a6470;
|
||||||
|
--line: #d8dde3;
|
||||||
|
--bg-soft: #f5f5fb; /* tint this toward your accent if you like */
|
||||||
|
--code-bg: #1e2a3a;
|
||||||
|
--code-fg: #e6e6e6;
|
||||||
|
--plus: #2c8a3e; /* tip / positive */
|
||||||
|
--minus: #c0392b; /* warning / negative */
|
||||||
|
--neutral: #b8860b; /* deep-dive / aside */
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: Charter, "Source Serif Pro", Georgia, serif;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 10.5pt;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
COVER
|
||||||
|
============================================================ */
|
||||||
|
.cover {
|
||||||
|
page: cover;
|
||||||
|
page-break-after: always;
|
||||||
|
height: 297mm;
|
||||||
|
background: linear-gradient(150deg, var(--accent-darker) 0%, var(--accent-dark) 45%, var(--accent) 100%);
|
||||||
|
color: white;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 22mm;
|
||||||
|
}
|
||||||
|
.cover-logo {
|
||||||
|
width: 30mm; height: 30mm;
|
||||||
|
background: rgba(255,255,255,0.14);
|
||||||
|
border: 1.5pt solid rgba(255,255,255,0.5);
|
||||||
|
border-radius: 7mm;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 20pt; font-weight: 800;
|
||||||
|
margin-bottom: 14mm;
|
||||||
|
letter-spacing: -1pt;
|
||||||
|
}
|
||||||
|
.cover h1 {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 42pt;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -1.5pt;
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
}
|
||||||
|
.cover h1 .light { font-weight: 300; display:block; font-size: 30pt; opacity: 0.85; }
|
||||||
|
.cover .sub {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.9;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 130mm;
|
||||||
|
margin-bottom: 18mm;
|
||||||
|
}
|
||||||
|
.cover .meta-row {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
opacity: 0.85;
|
||||||
|
border-top: 1pt solid rgba(255,255,255,0.3);
|
||||||
|
padding-top: 5mm;
|
||||||
|
display: flex;
|
||||||
|
gap: 8mm;
|
||||||
|
}
|
||||||
|
.cover .meta-row b { font-weight: 700; }
|
||||||
|
.cover-deco {
|
||||||
|
position: absolute;
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 120pt;
|
||||||
|
font-weight: 800;
|
||||||
|
opacity: 0.06;
|
||||||
|
bottom: 10mm;
|
||||||
|
right: 12mm;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
TABLE OF CONTENTS
|
||||||
|
============================================================ */
|
||||||
|
.toc {
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
.toc h2 {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 22pt;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
margin-bottom: 8mm;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.toc-part {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5pt;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 6mm 0 2mm 0;
|
||||||
|
padding-bottom: 1mm;
|
||||||
|
border-bottom: 1pt solid var(--line);
|
||||||
|
}
|
||||||
|
.toc-part:first-of-type { margin-top: 0; }
|
||||||
|
.toc-entry {
|
||||||
|
display: flex;
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
margin: 1.6mm 0;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.toc-num {
|
||||||
|
color: var(--accent-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
width: 11mm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toc-title { color: var(--ink); }
|
||||||
|
.toc-dots {
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: 1pt dotted var(--line);
|
||||||
|
margin: 0 2mm;
|
||||||
|
transform: translateY(-1mm);
|
||||||
|
}
|
||||||
|
.toc-page { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PART DIVIDER
|
||||||
|
============================================================ */
|
||||||
|
.part-divider {
|
||||||
|
page: chapterstart;
|
||||||
|
page-break-before: always;
|
||||||
|
page-break-after: always;
|
||||||
|
height: 257mm;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-radius: 4mm;
|
||||||
|
padding: 0 20mm;
|
||||||
|
}
|
||||||
|
.part-divider .part-kicker {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 3pt;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 4mm;
|
||||||
|
}
|
||||||
|
.part-divider h1 {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 34pt;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--accent-darker);
|
||||||
|
letter-spacing: -1pt;
|
||||||
|
line-height: 1.05;
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
}
|
||||||
|
.part-divider .part-desc {
|
||||||
|
font-size: 12pt;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
max-width: 120mm;
|
||||||
|
}
|
||||||
|
.part-divider .part-chapters {
|
||||||
|
margin-top: 10mm;
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
.part-divider .part-chapters span {
|
||||||
|
display: block;
|
||||||
|
margin: 1.5mm 0;
|
||||||
|
padding-left: 5mm;
|
||||||
|
border-left: 2pt solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
CHAPTER
|
||||||
|
============================================================ */
|
||||||
|
.chapter {
|
||||||
|
margin-top: 8mm; /* chapters flow continuously; no forced page break */
|
||||||
|
}
|
||||||
|
.chapter-head {
|
||||||
|
string-set: chaptertitle content();
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
padding-bottom: 3mm;
|
||||||
|
border-bottom: 2pt solid var(--ink);
|
||||||
|
}
|
||||||
|
.chapter-num {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2pt;
|
||||||
|
color: var(--accent);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1.5mm;
|
||||||
|
}
|
||||||
|
.chapter-head h1 {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 24pt;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
letter-spacing: -0.5pt;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
IN-FLOW HEADINGS
|
||||||
|
============================================================ */
|
||||||
|
h2 {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 14pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
margin: 6mm 0 2.5mm 0;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
margin: 4mm 0 1.5mm 0;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin-bottom: 2.5mm;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
p b, li b { color: var(--accent-dark); }
|
||||||
|
ul, ol { margin: 1.5mm 0 3mm 6mm; }
|
||||||
|
li { margin-bottom: 1mm; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
CODE (optional — technical topics only, see §7)
|
||||||
|
============================================================ */
|
||||||
|
pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--code-fg);
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 8.3pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 3mm 4mm;
|
||||||
|
border-radius: 2mm;
|
||||||
|
margin: 2.5mm 0 3.5mm 0;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: hidden;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.c { color: #6b8aae; font-style: italic; } /* comment / de-emphasized */
|
||||||
|
.k { color: #ff79c6; } /* keyword / control word */
|
||||||
|
.s { color: #f1c40f; } /* string / number / literal value */
|
||||||
|
.f { color: #50fa7b; } /* function / callable name */
|
||||||
|
.t { color: #8be9fd; } /* type / tag / class name */
|
||||||
|
.v { color: #ffb86c; } /* variable / identifier */
|
||||||
|
.a { color: #bd93f9; } /* attribute / annotation / decorator */
|
||||||
|
|
||||||
|
code.inline {
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 9pt;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
padding: 0.3mm 1.5mm;
|
||||||
|
border-radius: 1mm;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
CALLOUTS
|
||||||
|
============================================================ */
|
||||||
|
.callout {
|
||||||
|
border-radius: 2mm;
|
||||||
|
padding: 2.5mm 4mm;
|
||||||
|
margin: 3mm 0;
|
||||||
|
font-size: 10pt;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 6mm 1fr;
|
||||||
|
gap: 3mm;
|
||||||
|
}
|
||||||
|
.callout-icon {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 14pt;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.callout-body > b:first-child {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 8pt;
|
||||||
|
letter-spacing: 1pt;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
}
|
||||||
|
.callout.tip { background: #e8f4ea; border-left: 3pt solid var(--plus); }
|
||||||
|
.callout.tip .callout-icon, .callout.tip .callout-body > b:first-child { color: var(--plus); }
|
||||||
|
.callout.warn { background: #fdecea; border-left: 3pt solid var(--minus); }
|
||||||
|
.callout.warn .callout-icon, .callout.warn .callout-body > b:first-child { color: var(--minus); }
|
||||||
|
.callout.note { background: var(--bg-soft); border-left: 3pt solid var(--accent); }
|
||||||
|
.callout.note .callout-icon, .callout.note .callout-body > b:first-child { color: var(--accent-dark); }
|
||||||
|
.callout.deep { background: #fff8e8; border-left: 3pt solid var(--neutral); }
|
||||||
|
.callout.deep .callout-icon, .callout.deep .callout-body > b:first-child { color: var(--neutral); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
TABLES
|
||||||
|
============================================================ */
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 2.5mm 0 3.5mm 0;
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: var(--accent-dark);
|
||||||
|
color: white;
|
||||||
|
text-align: left;
|
||||||
|
padding: 1.8mm 3mm;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 1.5mm 3mm;
|
||||||
|
border-bottom: 1pt solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
tr:nth-child(even) td { background: var(--bg-soft); }
|
||||||
|
td code, th code {
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 8.2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* small helpers */
|
||||||
|
.lead {
|
||||||
|
font-size: 11.5pt;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 4mm;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The only things you customize: `:root` variables
|
||||||
|
|
||||||
|
Change **four** values per guide; leave the rest unless you have a reason. The accent color is the entire brand: pick one that fits the topic, then derive a darker and darkest shade from it (roughly: accent at ~55% lightness, accent-dark ~40%, accent-darker ~28%, all at similar hue).
|
||||||
|
|
||||||
|
| Variable | What it is | Example (PHP) |
|
||||||
|
|---|---|---|
|
||||||
|
| `--accent` | main accent (kickers, links, code-inline, table tint sources) | `#777BB4` |
|
||||||
|
| `--accent-dark` | headings, table header background | `#4F5B93` |
|
||||||
|
| `--accent-darker` | cover gradient end + part titles | `#2C3E66` |
|
||||||
|
| `--footer-label` | short title shown bottom-right every page; **must be a quoted CSS string** | `"PHP Komplett-Guide"` |
|
||||||
|
|
||||||
|
### Recommended accent palettes (free to ignore or replace)
|
||||||
|
|
||||||
|
These are starting points — three shades each (accent / dark / darker):
|
||||||
|
|
||||||
|
- **Programming / PHP** — `#777BB4` / `#4F5B93` / `#2C3E66` (violet)
|
||||||
|
- **Godot / game dev** — `#478CBF` / `#3A6E97` / `#27496B` (blue)
|
||||||
|
- **Blender / 3D / art** — `#E87D0D` / `#C2670A` / `#8A4906` (orange)
|
||||||
|
- **Finance / business** — `#1F7A4D` / `#155C39` / `#0D3D26` (green)
|
||||||
|
- **Nutrition / health** — `#D2603A` / `#A8492B` / `#74331E` (terracotta)
|
||||||
|
- **Psychology / communication** — `#6A5ACD` / `#52459E` / `#372E6B` (indigo)
|
||||||
|
- **Language learning** — `#C0392B` / `#992D22` / `#641E16` (warm red)
|
||||||
|
- **Neutral / mixed topics** — `#3D5A73` / `#2E4557` / `#1E2E3B` (slate)
|
||||||
|
|
||||||
|
When you change the accent, optionally nudge `--bg-soft` toward the same hue (keep it very light, ~96% lightness) so tinted blocks feel cohesive. Everything else (`--ink`, `--muted`, `--line`, callout colors, code colors) stays fixed — those are deliberately topic-independent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. HTML skeleton
|
||||||
|
|
||||||
|
The body is a flat sequence of sections. There is no wrapper around chapters; the CSS handles page breaks. Order: cover, TOC, then for each part its divider immediately followed by its chapters.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de"> <!-- set to the guide's language -->
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>… Guide title …</title>
|
||||||
|
<style>
|
||||||
|
/* paste the entire stylesheet from §3 here */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- COVER -->
|
||||||
|
<section class="cover"> … </section>
|
||||||
|
|
||||||
|
<!-- TABLE OF CONTENTS -->
|
||||||
|
<section class="toc"> … </section>
|
||||||
|
|
||||||
|
<!-- PART 1 -->
|
||||||
|
<section class="part-divider"> … </section>
|
||||||
|
<section class="chapter"> … </section>
|
||||||
|
<section class="chapter"> … </section>
|
||||||
|
<!-- … more chapters … -->
|
||||||
|
|
||||||
|
<!-- PART 2 -->
|
||||||
|
<section class="part-divider"> … </section>
|
||||||
|
<section class="chapter"> … </section>
|
||||||
|
<!-- … etc … -->
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
You may generate this HTML however you like — by hand, or with a small script that concatenates strings. A script helps for long guides because it keeps each chapter in its own readable chunk. If you use a script, **the script is throwaway**; the deliverable is the HTML (and the PDF), not the script.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The building blocks
|
||||||
|
|
||||||
|
Below is the exact markup for every block. Copy these shapes; fill in content.
|
||||||
|
|
||||||
|
### 6.1 Cover
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section class="cover">
|
||||||
|
<div class="cover-logo">LOGO</div>
|
||||||
|
<h1>MAIN TITLE<span class="light">Subtitle line</span></h1>
|
||||||
|
<div class="sub">One or two sentences describing what the guide covers and who it is for.</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span><b>N Parts</b> · M Chapters</span>
|
||||||
|
<span><b>Edition / version</b> · Year</span>
|
||||||
|
<span>Focus: …</span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-deco">◆</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`.cover-logo`** — 1–4 characters or a single symbol that evokes the topic. Examples: `php`, `gd` (Godot), a Blender-style `b`, `€` (finance), `Ψ` (psychology), `EN` (English), `🍎` is **not** allowed (no emoji in the logo — keep it crisp). Prefer short letterforms or a geometric glyph (`◆ ● ▲ ■`).
|
||||||
|
- **`.cover h1`** — the big title. The `<span class="light">` is an optional lighter, smaller second line (e.g. `PHP` then `The Complete Guide`). Drop the span for a single-line title.
|
||||||
|
- **`.cover-deco`** — a giant, very faint background glyph in the bottom-right. Pick something topic-flavored: a code fragment (`<?php`, `{ }`, `def`), a symbol (`€`, `∑`, `Ψ`, `♪`), or a single bold letter. Keep it to a few characters; it is decorative and barely visible by design.
|
||||||
|
- **`.meta-row`** — two to four short facts separated into `<span>`s. Use it to state scope (parts/chapters), edition/year, and the focus. This is where you set the reader's expectations about length and depth.
|
||||||
|
|
||||||
|
### 6.2 Table of contents
|
||||||
|
|
||||||
|
List every part, and under it every chapter, numbered sequentially across the whole guide. There are **no real page numbers** (WeasyPrint does not back-fill them here), so the `.toc-page` element is optional — omit it, or use it only if you maintain numbers yourself. The dotted leader still looks right without a trailing number.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section class="toc">
|
||||||
|
<h2>Contents</h2>
|
||||||
|
|
||||||
|
<div class="toc-part">Part 1 · Fundamentals</div>
|
||||||
|
<div class="toc-entry">
|
||||||
|
<span class="toc-num">1</span>
|
||||||
|
<span class="toc-title">First chapter title</span>
|
||||||
|
<span class="toc-dots"></span>
|
||||||
|
</div>
|
||||||
|
<div class="toc-entry">
|
||||||
|
<span class="toc-num">2</span>
|
||||||
|
<span class="toc-title">Second chapter title</span>
|
||||||
|
<span class="toc-dots"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toc-part">Part 2 · Going Deeper</div>
|
||||||
|
<div class="toc-entry">
|
||||||
|
<span class="toc-num">3</span>
|
||||||
|
<span class="toc-title">…</span>
|
||||||
|
<span class="toc-dots"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Part divider
|
||||||
|
|
||||||
|
One full page that introduces a part. The chapter list mirrors the TOC entries for that part.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section class="part-divider">
|
||||||
|
<div class="part-kicker">Part 1</div>
|
||||||
|
<h1>Fundamentals</h1>
|
||||||
|
<div class="part-desc">Italic one-liner describing the arc of this part.</div>
|
||||||
|
<div class="part-chapters">
|
||||||
|
<span>1 · First chapter</span>
|
||||||
|
<span>2 · Second chapter</span>
|
||||||
|
<span>3 · Third chapter</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Chapter
|
||||||
|
|
||||||
|
The `.chapter-head` is special: its text is captured into the **running header** at the top-right of every page in that chapter (via `string-set: chaptertitle content()`). Note that `content()` concatenates the text of **all** children, so the chapter-number span and the title run together in the header (e.g. "Chapter 1Title"). For a clean separator, end the `.chapter-num` text with a trailing separator such as `Chapter 1 · ` (trailing space + middot) — or accept the run-on; both are acceptable. Either way keep the chapter `<h1>` reasonably short so it fits on one header line.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section class="chapter">
|
||||||
|
<div class="chapter-head">
|
||||||
|
<span class="chapter-num">Chapter 1</span>
|
||||||
|
<h1>Chapter title</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="lead">Framing sentence(s) — why this matters.</p>
|
||||||
|
|
||||||
|
<h2>A section</h2>
|
||||||
|
<p>Body text. Use <b>bold</b> for the key term in a sentence. Inline monospace
|
||||||
|
like <code class="inline">term</code> works for any short literal —
|
||||||
|
a command, a key name, a nutrient, a chord, a German case.</p>
|
||||||
|
|
||||||
|
<h3>A sub-section</h3>
|
||||||
|
<p>…</p>
|
||||||
|
|
||||||
|
<!-- tables, callouts, code, lists as needed -->
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Callouts
|
||||||
|
|
||||||
|
Four flavors. Each has a short uppercase label as the **first `<b>`** inside `.callout-body`, then body text. The icon column holds one glyph.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="callout tip">
|
||||||
|
<div class="callout-icon">✓</div>
|
||||||
|
<div class="callout-body"><b>LABEL</b>Body text giving a practical tip.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout warn">
|
||||||
|
<div class="callout-icon">!</div>
|
||||||
|
<div class="callout-body"><b>LABEL</b>Body text warning about a trap.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout note">
|
||||||
|
<div class="callout-icon">i</div>
|
||||||
|
<div class="callout-body"><b>LABEL</b>Body text for a neutral side note.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout deep">
|
||||||
|
<div class="callout-icon">◆</div>
|
||||||
|
<div class="callout-body"><b>LABEL</b>Body text for an optional deeper dive.</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Meaning of each flavor (topic-independent):
|
||||||
|
|
||||||
|
| Flavor | Color | Use for |
|
||||||
|
|---|---|---|
|
||||||
|
| `tip` | green, ✓ | best practice, a recommendation, a shortcut |
|
||||||
|
| `warn` | red, ! | a common mistake, risk, or thing to avoid |
|
||||||
|
| `note` | accent, i | a neutral aside, clarification, context |
|
||||||
|
| `deep` | gold, ◆ | optional depth: history, edge case, advanced detail |
|
||||||
|
|
||||||
|
Standard icon entities: tip `✓` (✓), warn `!`, note `i`, deep `◆` (◆). You may substitute a more fitting single glyph, but keep it one character.
|
||||||
|
|
||||||
|
### 6.6 Tables
|
||||||
|
|
||||||
|
Plain `<table>` with a header row. The styling (accent header, zebra rows) is automatic. Keep tables to a few columns so they fit A4 width.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<table>
|
||||||
|
<tr><th>Column A</th><th>Column B</th></tr>
|
||||||
|
<tr><td>value</td><td>value</td></tr>
|
||||||
|
<tr><td>value</td><td>value</td></tr>
|
||||||
|
</table>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tables are one of the most useful blocks for **non-technical** topics too: nutrient comparisons, vocabulary lists, pros/cons, decision matrices, dosage/timing, term glossaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Code blocks — optional, technical topics only
|
||||||
|
|
||||||
|
The dark `<pre>` block and the highlight span classes exist for subjects that genuinely involve code or other monospaced literal text (config, shell commands, formulas). **For non-technical guides, do not use `<pre>` blocks at all** — they would look out of place. Use tables, lists, callouts, and worked examples instead. (`code.inline` is fine everywhere for short literals.)
|
||||||
|
|
||||||
|
When you do use code, you **hand-write the highlighting** by wrapping tokens in spans. The classes are intentionally generic so they map onto any language:
|
||||||
|
|
||||||
|
| Class | Generic meaning | Typical use |
|
||||||
|
|---|---|---|
|
||||||
|
| `.c` | de-emphasized | comments |
|
||||||
|
| `.k` | keyword / control word | `if`, `function`, `class`, `return` |
|
||||||
|
| `.s` | literal value | strings, numbers |
|
||||||
|
| `.f` | callable name | function / method names |
|
||||||
|
| `.t` | type / tag / class | type names, HTML tags, class names |
|
||||||
|
| `.v` | identifier | variables |
|
||||||
|
| `.a` | annotation | attributes, decorators, directives |
|
||||||
|
|
||||||
|
Rules for code blocks:
|
||||||
|
|
||||||
|
- Keep snippets **short** (roughly 2–12 lines). They must fit on one page — `page-break-inside: avoid` is set, so an oversized block can overflow. Split long examples into several captioned blocks.
|
||||||
|
- **Escape HTML inside code**: write `<`, `>`, `&`. This is the most common rendering bug.
|
||||||
|
- Inside the dark block, a comment uses `<span class="c">`. Indentation is literal spaces (the block is `white-space: pre`).
|
||||||
|
- You do not need to highlight every token — highlight the ones that aid reading (keywords, strings, names). Plain text inside `<pre>` is fine and renders in the default light color.
|
||||||
|
|
||||||
|
Example (PHP-flavored, but the pattern is language-agnostic):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<pre><span class="k">function</span> <span class="f">greet</span>(<span class="t">string</span> <span class="v">$name</span>): <span class="t">string</span> {
|
||||||
|
<span class="k">return</span> <span class="s">"Hello, $name"</span>; <span class="c">// interpolation</span>
|
||||||
|
}</pre>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Pitfalls — read before building (these caused real bugs)
|
||||||
|
|
||||||
|
1. **Escape `<`, `>`, `&` inside `<pre>` and `<code>`.** Unescaped angle brackets silently swallow content or break layout. Always `< > &`.
|
||||||
|
2. **Typographic quotes in prose, straight quotes in attributes.** In body text use the target language's real quotation marks (German `„ … "`, English `" … "`). Never let a quote character inside running text collide with HTML attribute quotes. If you generate the HTML from a script, be careful that closing typographic quotes are not accidentally written as escaped ASCII quotes — that corrupts both the string and any nearby `class="…"`. The safe approach: type real `„ … "` / `" … "` glyphs in prose, and reserve `"` strictly for HTML attributes.
|
||||||
|
3. **`--footer-label` must be a quoted CSS string**, e.g. `--footer-label: "Nutrition Guide";`. An unquoted value breaks the `@page` rule.
|
||||||
|
4. **Keep code blocks and callouts short enough to fit one page.** `page-break-inside: avoid` prevents splitting but cannot shrink an oversized block; it will overflow the page. Break large blocks up.
|
||||||
|
5. **Multibyte text is fine** (umlauts, accents, CJK, symbols) — the fonts and UTF-8 charset handle it. But if you ever measure string length in a generator script, count characters, not bytes.
|
||||||
|
6. **Chapter `<h1>` feeds the running header.** Keep it short; a very long chapter title wraps awkwardly in the 8pt header.
|
||||||
|
7. **Don't add web fonts or external assets.** The look depends on the system-font stack already specified. Adding fonts changes metrics and spacing.
|
||||||
|
8. **One accent, three shades.** Don't introduce extra brand colors. Variety comes from the callout colors (green/red/gold), which are fixed and meaningful — not decorative.
|
||||||
|
9. **Tables and code don't split** (`page-break-inside: avoid`). If a table is very long, either let it be its own short section or split it into two.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Build & verify
|
||||||
|
|
||||||
|
1. Write `guide.html` (single file, embedded `<style>`, UTF-8).
|
||||||
|
2. Convert:
|
||||||
|
```
|
||||||
|
weasyprint guide.html guide.pdf
|
||||||
|
```
|
||||||
|
3. **Verify visually.** Render a few pages to images and look at them — do not trust the HTML alone:
|
||||||
|
```
|
||||||
|
pdftoppm -png -r 80 -f 1 -l 1 guide.pdf cover # cover
|
||||||
|
pdftoppm -png -r 80 -f 2 -l 2 guide.pdf toc # contents
|
||||||
|
pdftoppm -png -r 80 -f 5 -l 6 guide.pdf body # a content spread
|
||||||
|
```
|
||||||
|
Check: cover gradient and title; TOC alignment; a part divider; at least one callout; at least one table; (if technical) one code block with highlighting; the running header showing the chapter title; no block overflowing a page edge; quotes and special characters rendering as glyphs, not boxes.
|
||||||
|
4. If something is off, fix the HTML and rebuild. Two or three iterations is normal.
|
||||||
|
5. Deliver **both** `guide.html` and `guide.pdf`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Quick checklist
|
||||||
|
|
||||||
|
- [ ] Single self-contained HTML, embedded `<style>`, no external assets
|
||||||
|
- [ ] `:root` accent shades + `--footer-label` set for the topic
|
||||||
|
- [ ] Cover: logo glyph, title, subtitle, meta-row, faint deco glyph
|
||||||
|
- [ ] TOC lists every part and every chapter, sequential numbering
|
||||||
|
- [ ] Each part has a divider; each chapter starts with `.chapter-head` + `.lead`
|
||||||
|
- [ ] Callouts used sparingly (≤ ~2 per chapter), correct flavor
|
||||||
|
- [ ] Explanations ≤ 3 short main clauses; recommendations/warnings live in callouts, not prose
|
||||||
|
- [ ] Tables for comparisons/lists; code blocks only if technical, short, escaped
|
||||||
|
- [ ] Prose in the reader's language; terms explained on first use
|
||||||
|
- [ ] Built with WeasyPrint and **visually verified** by rendering pages
|
||||||
|
- [ ] Both HTML and PDF delivered
|
||||||
|
|
||||||
|
## 11. Dark mode (required)
|
||||||
|
|
||||||
|
The app toggles dark mode by setting `data-theme="dark"` on `<html>` inside its preview. Print/PDF always stays light.
|
||||||
|
|
||||||
|
- Define every color through the `:root` variables only (`--ink`, `--muted`, `--line`, `--bg-soft`, accent trio).
|
||||||
|
- Ship an additional override block:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media screen {
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--ink: #e6e8ee; --muted: #9aa3b2; --line: #2c3038; --bg-soft: #23262e;
|
||||||
|
/* lift accent shades so headings/links stay readable on dark */
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] body { background: #15171c; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Do NOT use `prefers-color-scheme` — the app controls the attribute.
|
||||||
|
- Do not hardcode callout/infobox backgrounds — or darken them explicitly in the dark block (e.g. to `var(--bg-soft)`); the colored border stays.
|
||||||
|
- Keep dark rules inside `@media screen` only, so WeasyPrint/PDF renders the light theme.
|
||||||
|
- Elements with light text on accent backgrounds (table headers, cover) may keep their light-theme background.
|
||||||
170
templates/Format/MiniGuide.md
Normal file
170
templates/Format/MiniGuide.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
```
|
||||||
|
MINI-GUIDE STYLE (HTML/CSS → PDF via WeasyPrint)
|
||||||
|
|
||||||
|
FORMAT
|
||||||
|
- A4 portrait, 3-5 pages
|
||||||
|
- @page { size: A4; margin: 18mm 18mm 16mm 18mm; }
|
||||||
|
- Footer: page number center, guide title right
|
||||||
|
|
||||||
|
COVERAGE (lowest guide tier)
|
||||||
|
- ALL core building blocks of the topic — what you need to get started.
|
||||||
|
Which ones those are depends on the topic.
|
||||||
|
- The topic inventory is produced beforehand by a research agent and
|
||||||
|
supplied. It is binding: every item gets a section.
|
||||||
|
- Sections without chapter structure; code examples very short (2-7 lines)
|
||||||
|
- ~15-25 min reading time, 3-5 pages — this upper limit always applies
|
||||||
|
|
||||||
|
TARGET AUDIENCE — COMPACT BEGINNER INTRO
|
||||||
|
- Real beginners with no programming experience in the topic
|
||||||
|
- Assumes only general understanding ("what is programming")
|
||||||
|
- Terms are explained on first appearance
|
||||||
|
- NO language-character overview for experienced developers
|
||||||
|
- NO advanced features (even if cool and short)
|
||||||
|
|
||||||
|
CONTENT PRINCIPLES
|
||||||
|
- Show only absolute basics
|
||||||
|
- Topics someone can reproduce themselves after 15 min
|
||||||
|
- No tooling complexity (package managers, build systems, compilers)
|
||||||
|
- No language specialties (type systems, decorators, generics)
|
||||||
|
- No OOP if possible (or only the most trivial form)
|
||||||
|
- Depth of explanation before breadth of features
|
||||||
|
- Better 5 concepts thoroughly than 15 superficially
|
||||||
|
|
||||||
|
TYPICAL 5-SECTION STRUCTURE
|
||||||
|
1. Starting the language — installation, first file, first program
|
||||||
|
2. Variables — concept + 2-3 basic types
|
||||||
|
3. Control flow — if/else with a simple example
|
||||||
|
4. Lists + iteration — array + loop
|
||||||
|
5. Functions — declaration + call + return value
|
||||||
|
|
||||||
|
(This order builds on itself and ends with something meaningful.)
|
||||||
|
|
||||||
|
STRUCTURE
|
||||||
|
1. Compact head: logo left (16mm), title + subtitle center, badge + time right
|
||||||
|
2. Gap opener: opening question, italic and framed, low-threshold
|
||||||
|
3. 4-6 H2 sections with explanatory text + code example + optional callout
|
||||||
|
|
||||||
|
ELEMENTS
|
||||||
|
- Body text: justify with hyphenation
|
||||||
|
- Code blocks: dark background, syntax highlighting, very short (2-7 lines)
|
||||||
|
- Inline code: light background, accent color
|
||||||
|
- Tables: only if truly necessary (comparison operators etc.)
|
||||||
|
- Callouts in 3 flavors: tip (green), warn (red), note (accent color)
|
||||||
|
|
||||||
|
TYPOGRAPHY
|
||||||
|
- Body: 10.5pt serif (Charter), line-height 1.55
|
||||||
|
- Head h1: 20pt sans-serif bold
|
||||||
|
- H2 section: 13pt sans-serif bold
|
||||||
|
- Code: 8.5pt monospace, line-height 1.5
|
||||||
|
- Inline code: 9pt monospace
|
||||||
|
- Callout labels: 8pt uppercase, letter-spacing 1pt
|
||||||
|
|
||||||
|
COLORS (max 3 + neutrals)
|
||||||
|
- Accent color: strong, leaning on the topic's official color
|
||||||
|
- Accent dark: darker variant for accents
|
||||||
|
- Background soft: light variant of the accent color
|
||||||
|
- Code background: #1e2a3a
|
||||||
|
- Text: #1a1a1a / muted #5a6470 / line #d8dde3
|
||||||
|
- Callout colors: green/red/accent
|
||||||
|
|
||||||
|
GAP OPENER (REQUIRED)
|
||||||
|
- Italic, framed with an accent-color border
|
||||||
|
- Low-threshold question that the guide answers
|
||||||
|
- Sparks interest with a relevant statistic or practical angle
|
||||||
|
- NO technical terms that have not been explained yet
|
||||||
|
- Examples:
|
||||||
|
- "PHP läuft hinter rund drei Viertel aller Webseiten..."
|
||||||
|
- "JavaScript ist die Sprache des Webs – aber wie schreibt man das eigentliche Code..."
|
||||||
|
- "Python ist die beliebteste Anfänger-Sprache..."
|
||||||
|
|
||||||
|
EXPLANATION DEPTH PER CONCEPT
|
||||||
|
- Name the concept (e.g. "Variable")
|
||||||
|
- Explain in one sentence what it is
|
||||||
|
- Code example with comments
|
||||||
|
- Mention language quirks (e.g. "in PHP beginnen Variablen mit $")
|
||||||
|
- NO reference to concepts that come later
|
||||||
|
|
||||||
|
CALLOUT USAGE
|
||||||
|
- tip (green): practice suggestion at the end, encouraging
|
||||||
|
- warn (red): beginner pitfalls ("= vs ==", "vergessenes Semikolon")
|
||||||
|
- note (accent): background info, explanation of a language quirk
|
||||||
|
|
||||||
|
CALLOUT CSS IMPORTANT
|
||||||
|
- .callout-body > b:first-child with display:block for the label
|
||||||
|
- NOT .callout-body b globally with display:block (destroys inline bold)
|
||||||
|
|
||||||
|
GAP CSS IMPORTANT
|
||||||
|
- .gap > b:first-child with display:block for the "FRAGE ZUM EINSTIEG" label
|
||||||
|
- NOT .gap b globally with display:block (destroys inline bold in the
|
||||||
|
question text)
|
||||||
|
|
||||||
|
TOPIC-SPECIFIC ADJUSTMENTS (choose before generating)
|
||||||
|
- Accent color: the topic's official color
|
||||||
|
- Logo letter(s) or abbreviation
|
||||||
|
- Welcome statistic in the gap opener
|
||||||
|
- Choose 4-6 beginner topics (see standard structure)
|
||||||
|
|
||||||
|
REQUIRED ELEMENTS
|
||||||
|
- 1 gap opener at the start
|
||||||
|
- 5-7 code examples (short, 2-7 lines, beginner-friendly)
|
||||||
|
- At least 1 callout (often: warn for a pitfall, tip for practice at the end)
|
||||||
|
- Inline code for technical terms
|
||||||
|
|
||||||
|
AVOID
|
||||||
|
- TOC or cover (oversized for 15 min)
|
||||||
|
- Introductory filler ("In diesem Mini-Guide lernen wir...")
|
||||||
|
- Claims of completeness (belongs in a larger guide)
|
||||||
|
- Reference tables without explanatory text (belongs in a cheatsheet)
|
||||||
|
- Recall or next-step at the end (a mini-guide ends with content)
|
||||||
|
- Topics that are advanced (even if cool):
|
||||||
|
- Type systems, type hints, generics
|
||||||
|
- OOP features (except the most trivial form)
|
||||||
|
- Tooling (package managers, build, linting)
|
||||||
|
- Language specialties (PHP: strict_types, readonly, Composer, PSR-4)
|
||||||
|
- page break in the middle of a code block or callout
|
||||||
|
(page-break-inside: avoid)
|
||||||
|
- More than 3 font sizes per section
|
||||||
|
- Floats or absolute positioning (breaks in WeasyPrint)
|
||||||
|
- Technical terms without explanation
|
||||||
|
- References to concepts that come later
|
||||||
|
- Edge cases and "but" sentences
|
||||||
|
|
||||||
|
GENERATION WITH FEEDBACK LOOP (max 3 iterations)
|
||||||
|
1. Write the HTML
|
||||||
|
2. weasyprint file.html file.pdf
|
||||||
|
3. PDF to PNGs: convert all pages
|
||||||
|
4. View all pages
|
||||||
|
5. Check:
|
||||||
|
- Head clean (logo does not overlap the title)?
|
||||||
|
- Code blocks not torn across page breaks?
|
||||||
|
- Callouts fully visible?
|
||||||
|
- Inline bolds in callouts/gap correct (not rendered as blocks)?
|
||||||
|
- Footer with page number correct?
|
||||||
|
- Would a real beginner understand this?
|
||||||
|
- Were all technical terms explained on first appearance?
|
||||||
|
6. On problems: fix, repeat from step 2
|
||||||
|
7. Output after max 3 iterations
|
||||||
|
|
||||||
|
INSTALLATION
|
||||||
|
- pip install weasyprint pdf2image
|
||||||
|
- apt install poppler-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
DARK MODE (REQUIRED)
|
||||||
|
- Define all colors exclusively via :root variables
|
||||||
|
(--ink, --muted, --line, --bg-soft, accent variables).
|
||||||
|
- Additionally ship a dark-mode block:
|
||||||
|
@media screen {
|
||||||
|
html[data-theme="dark"] { ...dark variable values... }
|
||||||
|
html[data-theme="dark"] body { background: #15171c; }
|
||||||
|
}
|
||||||
|
- The app enables it via data-theme="dark" on <html>.
|
||||||
|
Do NOT use prefers-color-scheme.
|
||||||
|
- Dark values: dark backgrounds (#15171c / #23262e), light text (#e6e8ee),
|
||||||
|
muted lines (#2c3038). Lift accent colors so headings and links stay
|
||||||
|
readable on a dark background (check contrast). Elements with light text on
|
||||||
|
accent backgrounds (e.g. table headers) may keep their light background
|
||||||
|
color.
|
||||||
|
- Do not hardcode callout/infobox backgrounds — or darken them explicitly in
|
||||||
|
dark mode (e.g. to var(--bg-soft)); the colored border stays.
|
||||||
|
- Only inside @media screen — print/PDF stays light.
|
||||||
115
templates/Format/OnePager.md
Normal file
115
templates/Format/OnePager.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
```
|
||||||
|
ONEPAGER STYLE LANDSCAPE (HTML/CSS → PDF via WeasyPrint)
|
||||||
|
|
||||||
|
FORMAT
|
||||||
|
- A4 landscape (297mm × 210mm)
|
||||||
|
- @page { size: A4 landscape; margin: 0; }
|
||||||
|
- Padding: 9mm 11mm 9mm 11mm
|
||||||
|
|
||||||
|
LAYOUT
|
||||||
|
- Grid: hero+stats (auto) / divider / main (1fr, 3 columns) / footer (absolute)
|
||||||
|
- Stats bar integrated into the hero (right), saves vertical space
|
||||||
|
- Main: 3 equal-width columns, gap 4mm
|
||||||
|
- Columns internally: flex-column, gap 4mm
|
||||||
|
- 6 thematic blocks distributed (2 per column)
|
||||||
|
|
||||||
|
STRUCTURE (in this order)
|
||||||
|
1. Hero: logo left (22mm), title + subtitle center, 4 stats right
|
||||||
|
2. Divider: 1.5pt black line
|
||||||
|
3. Main grid: 6 blocks in 3 columns
|
||||||
|
4. Footer: colored box with key message + tag (absolute, bottom)
|
||||||
|
|
||||||
|
DIFFERENCES FROM PORTRAIT
|
||||||
|
- 3 columns instead of 2 (more horizontal space)
|
||||||
|
- 6 blocks instead of 4-5
|
||||||
|
- Stats integrated into the hero instead of their own row
|
||||||
|
- Smaller font sizes (9.5pt body instead of 10pt)
|
||||||
|
- More compact code blocks (7pt instead of 7.5pt)
|
||||||
|
|
||||||
|
BLOCK STRUCTURE
|
||||||
|
- Title: 9.5pt bold uppercase, icon left, accent-color underline 2pt
|
||||||
|
- Content: visual, not plain text bullets
|
||||||
|
- Variants: icon list, code block, tile grid, plus/minus columns, type grid
|
||||||
|
|
||||||
|
COLORS (max 3 + neutrals)
|
||||||
|
- Accent color: lean on the topic's official color
|
||||||
|
- Accent dark: for headings
|
||||||
|
- Accent darker: for the footer
|
||||||
|
- Accent color: contrasting
|
||||||
|
- Background soft: light variant of the accent color
|
||||||
|
- Code background: #1e2a3a
|
||||||
|
- Text: #1a1a1a / muted #5a6470 / line #e5e5e5
|
||||||
|
|
||||||
|
TYPOGRAPHY
|
||||||
|
- Body: 9.5pt, line-height 1.4
|
||||||
|
- Hero h1: 20pt bold
|
||||||
|
- Block title: 9.5pt bold uppercase, letter-spacing 0.5pt
|
||||||
|
- Stats number: 14pt bold, label 6.5pt uppercase
|
||||||
|
- Code: 7pt monospace, dark background
|
||||||
|
- Feature text: 8.5pt
|
||||||
|
- Max 3 font sizes per block
|
||||||
|
|
||||||
|
ICONS
|
||||||
|
- SVG inline, stroke instead of fill
|
||||||
|
- 4mm in block titles (smaller than portrait due to compact layout)
|
||||||
|
- 2.8mm in tile icons
|
||||||
|
- currentColor for automatic adaptation
|
||||||
|
|
||||||
|
TOPIC-SPECIFIC ADJUSTMENTS (choose before generating)
|
||||||
|
- Accent color: the topic's official color
|
||||||
|
- Logo letter(s) or abbreviation
|
||||||
|
- 4 stats: topic-relevant numbers
|
||||||
|
- Block selection: the 6 most important aspects for a first overview
|
||||||
|
|
||||||
|
REQUIRED VISUAL ELEMENTS
|
||||||
|
- At least 1 code block
|
||||||
|
- At least 1 tile grid with icons (ecosystem)
|
||||||
|
- At least 1 plus/minus split (modern vs legacy etc.)
|
||||||
|
- Footer as a colored box (visual anchor)
|
||||||
|
- Stats bar in the hero
|
||||||
|
|
||||||
|
AVOID
|
||||||
|
- Plain bullet lists in every block
|
||||||
|
- More than 6 main blocks (landscape already has more space)
|
||||||
|
- More than 3 font sizes
|
||||||
|
- Marketing filler in the hero
|
||||||
|
- Floats or absolute positioning (except for the footer)
|
||||||
|
- Vertically very long blocks (would unbalance the columns)
|
||||||
|
|
||||||
|
GENERATION WITH FEEDBACK LOOP (max 3 iterations)
|
||||||
|
1. Write the HTML
|
||||||
|
2. weasyprint file.html file.pdf
|
||||||
|
3. PDF to PNG: python -c "from pdf2image import convert_from_path; convert_from_path('file.pdf', dpi=120)[0].save('preview.png')"
|
||||||
|
4. View the preview with the Read tool
|
||||||
|
5. Check:
|
||||||
|
- Stats bar does not overlap the title?
|
||||||
|
- 3 columns balanced (similar height)?
|
||||||
|
- Footer not cut off?
|
||||||
|
- Code block readable?
|
||||||
|
- All icons render?
|
||||||
|
6. On problems: fix, repeat from step 2
|
||||||
|
7. Output after max 3 iterations
|
||||||
|
|
||||||
|
INSTALLATION
|
||||||
|
- pip install weasyprint pdf2image
|
||||||
|
- apt install poppler-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
DARK MODE (REQUIRED)
|
||||||
|
- Define all colors exclusively via :root variables
|
||||||
|
(--ink, --muted, --line, --bg-soft, accent variables).
|
||||||
|
- Additionally ship a dark-mode block:
|
||||||
|
@media screen {
|
||||||
|
html[data-theme="dark"] { ...dark variable values... }
|
||||||
|
html[data-theme="dark"] body { background: #15171c; }
|
||||||
|
}
|
||||||
|
- The app enables it via data-theme="dark" on <html>.
|
||||||
|
Do NOT use prefers-color-scheme.
|
||||||
|
- Dark values: dark backgrounds (#15171c / #23262e), light text (#e6e8ee),
|
||||||
|
muted lines (#2c3038). Lift accent colors so headings and links stay
|
||||||
|
readable on a dark background (check contrast). Elements with light text on
|
||||||
|
accent backgrounds (e.g. table headers) may keep their light background
|
||||||
|
color.
|
||||||
|
- Do not hardcode callout/infobox backgrounds — or darken them explicitly in
|
||||||
|
dark mode (e.g. to var(--bg-soft)); the colored border stays.
|
||||||
|
- Only inside @media screen — print/PDF stays light.
|
||||||
44
templates/Format/Suche.md
Normal file
44
templates/Format/Suche.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
You create a topic taxonomy. A user describes a problem or learning goal.
|
||||||
|
Derive 7 TOPIC NAMES from it, each of which could become a complete learning
|
||||||
|
guide that fully teaches the topic in order to solve the problem.
|
||||||
|
|
||||||
|
The user's problem:
|
||||||
|
{problem}
|
||||||
|
|
||||||
|
ALREADY EXISTING TOPICS (reference — do NOT propose these again):
|
||||||
|
{existing}
|
||||||
|
|
||||||
|
CORE PRINCIPLE — MECE (Mutually Exclusive, Collectively Exhaustive):
|
||||||
|
The topics must be mutually exclusive (no topic is a subtopic of another, no
|
||||||
|
content overlap) and together cover the problem sensibly. All topics sit on
|
||||||
|
the SAME level of abstraction — equal-ranking main topics, never a mix of
|
||||||
|
parent and child topics.
|
||||||
|
|
||||||
|
Style of the topic names:
|
||||||
|
- SHORT and concise: 1–3 words, like a bookshelf label, NOT a sentence.
|
||||||
|
- Name the topic COMPLETELY, not a subset. Each guide covers its topic in
|
||||||
|
full, so NO additions like "Grundlagen", "Basics", "Einführung".
|
||||||
|
Right: "CSS" — Wrong: "CSS Grundlagen".
|
||||||
|
- NO colon, NO explanation, NO marketing language in the title.
|
||||||
|
- Examples of good style: "HTML", "CSS", "JavaScript", "Deployment", "Git".
|
||||||
|
|
||||||
|
Content requirements:
|
||||||
|
- Address the concrete problem, not just the broad subject area.
|
||||||
|
- For a vague problem: topics for the most likely interpretations.
|
||||||
|
- The topics do NOT have to exist already — choose well-nameable,
|
||||||
|
precise titles.
|
||||||
|
|
||||||
|
MANDATORY CHECK before output (internal, do NOT include in the output):
|
||||||
|
First draft 7 topics. Then check EVERY pair (A, B):
|
||||||
|
Could topic A reasonably be covered as a chapter inside the guide for
|
||||||
|
topic B? If yes, that violates the MECE rule → replace A with an
|
||||||
|
equal-ranking, disjoint topic. Repeat until no topic is a subtopic of
|
||||||
|
another. Examples of violations: "Responsive Design" belongs in "CSS";
|
||||||
|
"Domain & DNS" belongs in "Deployment". Only then output the final set.
|
||||||
|
|
||||||
|
OUTPUT:
|
||||||
|
Respond EXCLUSIVELY with a JSON array of 7 elements. Each element:
|
||||||
|
- "title": short topic name (max. 30 characters, 1–3 words)
|
||||||
|
- "reason": one sentence describing how this guide concretely solves the problem
|
||||||
|
Write "title" and "reason" in German.
|
||||||
|
No other text, only the JSON array.
|
||||||
106
templates/Referenz/Baustein.md
Normal file
106
templates/Referenz/Baustein.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
```
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Baustein: for-Schleife</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background: #f0f0f5;
|
||||||
|
padding: 40px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px 36px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beschreibung {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zweck {
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: italic;
|
||||||
|
color: #5a6470;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.examples {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: #1e2a3a;
|
||||||
|
color: #e6e6e6;
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block .label {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #8be9fd;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k { color: #ff79c6; }
|
||||||
|
.v { color: #ffb86c; }
|
||||||
|
.n { color: #ffb86c; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<h1>for-Schleife</h1>
|
||||||
|
|
||||||
|
<p class="beschreibung">Wiederholt einen Code-Block.</p>
|
||||||
|
|
||||||
|
<p class="zweck">Liste oder Bereich durchgehen.</p>
|
||||||
|
|
||||||
|
<div class="examples">
|
||||||
|
|
||||||
|
<div class="code-block"><span class="label">Vorwärts zählen</span><span class="k">for</span> (<span class="v">$i</span> = <span class="n">0</span>; <span class="v">$i</span> < <span class="n">10</span>; <span class="v">$i</span>++) {
|
||||||
|
<span class="k">echo</span> <span class="v">$i</span>;
|
||||||
|
}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
549
templates/Referenz/Cheatsheet.md
Normal file
549
templates/Referenz/Cheatsheet.md
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
```
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>PHP Cheatsheet</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4 landscape; margin: 0; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--php: #777BB4;
|
||||||
|
--php-dark: #4F5B93;
|
||||||
|
--php-darker: #2C3E66;
|
||||||
|
--ink: #1a1a1a;
|
||||||
|
--muted: #5a6470;
|
||||||
|
--line: #d8dde3;
|
||||||
|
--bg-soft: #f5f5fb;
|
||||||
|
--code-bg: #1e2a3a;
|
||||||
|
--code-fg: #e6e6e6;
|
||||||
|
--plus: #2c8a3e;
|
||||||
|
--minus: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Darkmode — die App setzt data-theme="dark" auf <html>; Druck/PDF bleibt hell */
|
||||||
|
@media screen {
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--ink: #e6e8ee;
|
||||||
|
--muted: #9aa3b2;
|
||||||
|
--line: #2c3038;
|
||||||
|
--bg-soft: #23262e;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] body { background: #15171c; }
|
||||||
|
html[data-theme="dark"] .callout { background: var(--bg-soft); }
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 8pt;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
width: 297mm;
|
||||||
|
height: 210mm;
|
||||||
|
padding: 7mm 9mm 18mm 9mm;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== HERO ===== */
|
||||||
|
.hero {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 2pt solid var(--ink);
|
||||||
|
padding-bottom: 2.5mm;
|
||||||
|
margin-bottom: 4mm;
|
||||||
|
}
|
||||||
|
.hero-logo, .hero-text, .hero-meta {
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.hero-logo { width: 18mm; padding-right: 4mm; }
|
||||||
|
.hero-logo-box {
|
||||||
|
width: 15mm; height: 15mm;
|
||||||
|
background: linear-gradient(135deg, var(--php) 0%, var(--php-dark) 100%);
|
||||||
|
border-radius: 4mm;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: white; font-size: 12pt; font-weight: 800;
|
||||||
|
}
|
||||||
|
.hero h1 { font-size: 18pt; font-weight: 800; letter-spacing: -0.5pt; line-height: 1; }
|
||||||
|
.hero h1 .accent { color: var(--php); }
|
||||||
|
.hero .sub { font-size: 9pt; color: var(--muted); margin-top: 1mm; }
|
||||||
|
.hero-meta { width: 35mm; text-align: right; }
|
||||||
|
.hero-meta .ver {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--ink); color: white;
|
||||||
|
padding: 1mm 3mm; border-radius: 1.5mm;
|
||||||
|
font-size: 9pt; font-weight: 700;
|
||||||
|
}
|
||||||
|
.hero-meta .tag {
|
||||||
|
display: block; margin-top: 1mm;
|
||||||
|
font-size: 7pt; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MAIN GRID: 4 columns ===== */
|
||||||
|
.main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
gap: 3mm;
|
||||||
|
}
|
||||||
|
.col { display: flex; flex-direction: column; gap: 3mm; }
|
||||||
|
|
||||||
|
/* ===== BLOCK ===== */
|
||||||
|
.block {
|
||||||
|
background: white;
|
||||||
|
border: 0.5pt solid var(--line);
|
||||||
|
border-radius: 2mm;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.block-head {
|
||||||
|
background: var(--php);
|
||||||
|
color: white;
|
||||||
|
padding: 1.3mm 2.5mm;
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5pt;
|
||||||
|
display: flex; align-items: center; gap: 2mm;
|
||||||
|
}
|
||||||
|
.block-head .ico { width: 3.5mm; height: 3.5mm; flex-shrink: 0; }
|
||||||
|
.block-body { padding: 1.8mm 2.5mm; }
|
||||||
|
|
||||||
|
/* ===== TABLES ===== */
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 7.5pt; }
|
||||||
|
td { padding: 0.8mm 1mm; vertical-align: top; border-bottom: 0.3pt solid var(--bg-soft); }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
td.k {
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 7pt;
|
||||||
|
color: var(--php-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
td.v { color: var(--ink); }
|
||||||
|
|
||||||
|
/* ===== CODE ===== */
|
||||||
|
.code {
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--code-fg);
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 6.5pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 1.5mm 2mm;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.c { color: #6b8aae; font-style: italic; }
|
||||||
|
.k { color: #ff79c6; }
|
||||||
|
.s { color: #f1c40f; }
|
||||||
|
.f { color: #50fa7b; }
|
||||||
|
.t { color: #8be9fd; }
|
||||||
|
.v { color: #ffb86c; }
|
||||||
|
|
||||||
|
/* ===== INLINE CODE ===== */
|
||||||
|
code.inline {
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 7pt;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
padding: 0.3mm 1mm;
|
||||||
|
border-radius: 0.8mm;
|
||||||
|
color: var(--php-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== KACHEL-GRID ===== */
|
||||||
|
.tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.2mm;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-left: 1.5pt solid var(--php);
|
||||||
|
border-radius: 1mm;
|
||||||
|
padding: 1.2mm 1.8mm;
|
||||||
|
display: flex; align-items: flex-start; gap: 1.5mm;
|
||||||
|
}
|
||||||
|
.tile-ico {
|
||||||
|
width: 3.5mm; height: 3.5mm; flex-shrink: 0;
|
||||||
|
background: var(--php-dark);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
margin-top: 0.2mm;
|
||||||
|
}
|
||||||
|
.tile-ico svg { width: 2mm; height: 2mm; }
|
||||||
|
.tile-text { font-size: 7pt; line-height: 1.25; }
|
||||||
|
.tile-text b { color: var(--php-dark); display: block; font-size: 7.5pt; }
|
||||||
|
.tile-text span { color: var(--muted); font-size: 6.3pt; }
|
||||||
|
|
||||||
|
/* ===== DIRECTIVES GRID ===== */
|
||||||
|
.dirgrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1mm;
|
||||||
|
}
|
||||||
|
.dir {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
padding: 0.8mm 1.3mm;
|
||||||
|
border-radius: 1mm;
|
||||||
|
font-size: 6.5pt;
|
||||||
|
}
|
||||||
|
.dir b { font-family: "SF Mono", Consolas, monospace; color: var(--php-dark); display: block; font-size: 7.3pt; }
|
||||||
|
.dir span { color: var(--muted); font-size: 6pt; }
|
||||||
|
|
||||||
|
/* ===== SPLIT ===== */
|
||||||
|
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 2mm; }
|
||||||
|
.split-col h4 {
|
||||||
|
font-size: 6.5pt; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4pt; margin-bottom: 0.8mm;
|
||||||
|
}
|
||||||
|
.split-col.plus h4 { color: var(--plus); }
|
||||||
|
.split-col.minus h4 { color: var(--minus); }
|
||||||
|
.split-col ul { list-style: none; }
|
||||||
|
.split-col li {
|
||||||
|
font-size: 7pt; padding-left: 2.5mm; position: relative;
|
||||||
|
margin-bottom: 0.5mm; line-height: 1.25;
|
||||||
|
}
|
||||||
|
.split-col.plus li::before { content: "+"; position: absolute; left: 0; color: var(--plus); font-weight: 800; }
|
||||||
|
.split-col.minus li::before { content: "−"; position: absolute; left: 0; color: var(--minus); font-weight: 800; }
|
||||||
|
|
||||||
|
/* ===== FOOTER ===== */
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
left: 9mm;
|
||||||
|
right: 9mm;
|
||||||
|
bottom: 7mm;
|
||||||
|
background: var(--php-darker);
|
||||||
|
color: white;
|
||||||
|
border-radius: 2mm;
|
||||||
|
padding: 2.5mm 4mm;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr auto;
|
||||||
|
gap: 4mm;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 7pt;
|
||||||
|
}
|
||||||
|
.foot-item { line-height: 1.3; }
|
||||||
|
.foot-item b { color: #F1C40F; display: block; font-size: 6.5pt; text-transform: uppercase; letter-spacing: 0.5pt; margin-bottom: 0.5mm; }
|
||||||
|
.foot-item code {
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 6.5pt;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
padding: 0.3mm 1mm;
|
||||||
|
border-radius: 0.8mm;
|
||||||
|
}
|
||||||
|
.foot-tag {
|
||||||
|
background: var(--php); color: white;
|
||||||
|
padding: 1.5mm 3mm; border-radius: 1.5mm;
|
||||||
|
font-weight: 800; font-size: 8pt;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<!-- HERO -->
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-logo"><div class="hero-logo-box">php</div></div>
|
||||||
|
<div class="hero-text">
|
||||||
|
<h1><span class="accent">PHP</span> Cheatsheet</h1>
|
||||||
|
<div class="sub">Syntax, OOP, Type-System, Standard-Funktionen & Ökosystem auf einen Blick</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<span class="ver">8.4</span>
|
||||||
|
<span class="tag">Stand 2026</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- MAIN -->
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<!-- SPALTE 1 -->
|
||||||
|
<div class="col">
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M3 6h18M3 12h18M3 18h18"/>
|
||||||
|
</svg>
|
||||||
|
Variablen & Typen
|
||||||
|
</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<table>
|
||||||
|
<tr><td class="k">$x = 42</td><td class="v">int</td></tr>
|
||||||
|
<tr><td class="k">$x = 3.14</td><td class="v">float</td></tr>
|
||||||
|
<tr><td class="k">$x = "Hi"</td><td class="v">string</td></tr>
|
||||||
|
<tr><td class="k">$x = true</td><td class="v">bool</td></tr>
|
||||||
|
<tr><td class="k">$x = null</td><td class="v">null</td></tr>
|
||||||
|
<tr><td class="k">$x = [1,2,3]</td><td class="v">array</td></tr>
|
||||||
|
<tr><td class="k">$x = ['k'=>1]</td><td class="v">assoc array</td></tr>
|
||||||
|
<tr><td class="k">gettype($x)</td><td class="v">Typ-Name</td></tr>
|
||||||
|
<tr><td class="k">is_int($x)</td><td class="v">Typ-Check</td></tr>
|
||||||
|
<tr><td class="k">(int)$x</td><td class="v">Cast</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polygon points="13,2 3,14 12,14 11,22 21,10 12,10"/>
|
||||||
|
</svg>
|
||||||
|
String-Funktionen
|
||||||
|
</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<table>
|
||||||
|
<tr><td class="k">strlen($s)</td><td class="v">Länge</td></tr>
|
||||||
|
<tr><td class="k">strtolower</td><td class="v">klein</td></tr>
|
||||||
|
<tr><td class="k">strtoupper</td><td class="v">groß</td></tr>
|
||||||
|
<tr><td class="k">trim($s)</td><td class="v">Whitespace weg</td></tr>
|
||||||
|
<tr><td class="k">explode(',', $s)</td><td class="v">→ array</td></tr>
|
||||||
|
<tr><td class="k">implode(',', $a)</td><td class="v">→ string</td></tr>
|
||||||
|
<tr><td class="k">str_replace</td><td class="v">a → b</td></tr>
|
||||||
|
<tr><td class="k">str_contains</td><td class="v">enthält?</td></tr>
|
||||||
|
<tr><td class="k">sprintf("%d", 1)</td><td class="v">format</td></tr>
|
||||||
|
<tr><td class="k">"$name"</td><td class="v">Interpolation</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SPALTE 2 -->
|
||||||
|
<div class="col">
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="16,18 22,12 16,6"/><polyline points="8,6 2,12 8,18"/>
|
||||||
|
</svg>
|
||||||
|
Moderne Klasse (PHP 8)
|
||||||
|
</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<div class="code"><span class="k">class</span> <span class="t">User</span> {
|
||||||
|
<span class="k">public function</span> <span class="f">__construct</span>(
|
||||||
|
<span class="k">public readonly</span> <span class="t">string</span> <span class="v">$name</span>,
|
||||||
|
<span class="k">public readonly</span> <span class="t">int</span> <span class="v">$age</span>,
|
||||||
|
<span class="k">private</span> ?<span class="t">string</span> <span class="v">$email</span> = <span class="k">null</span>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
<span class="k">public function</span> <span class="f">isAdult</span>(): <span class="t">bool</span> {
|
||||||
|
<span class="k">return</span> <span class="v">$this</span>->age >= <span class="s">18</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="v">$u</span> = <span class="k">new</span> <span class="t">User</span>(name: <span class="s">'Marek'</span>, age: <span class="s">34</span>);</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/>
|
||||||
|
</svg>
|
||||||
|
Array-Funktionen
|
||||||
|
</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<table>
|
||||||
|
<tr><td class="k">count($a)</td><td class="v">Anzahl</td></tr>
|
||||||
|
<tr><td class="k">array_map(fn, $a)</td><td class="v">transform</td></tr>
|
||||||
|
<tr><td class="k">array_filter</td><td class="v">filtern</td></tr>
|
||||||
|
<tr><td class="k">array_reduce</td><td class="v">aggregieren</td></tr>
|
||||||
|
<tr><td class="k">array_keys</td><td class="v">→ Keys</td></tr>
|
||||||
|
<tr><td class="k">array_values</td><td class="v">→ Values</td></tr>
|
||||||
|
<tr><td class="k">in_array($x, $a)</td><td class="v">enthält?</td></tr>
|
||||||
|
<tr><td class="k">array_merge</td><td class="v">a + b</td></tr>
|
||||||
|
<tr><td class="k">sort / usort</td><td class="v">sortieren</td></tr>
|
||||||
|
<tr><td class="k">array_unique</td><td class="v">dedupe</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SPALTE 3 -->
|
||||||
|
<div class="col">
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M7 17l10-10M17 17H7V7"/>
|
||||||
|
</svg>
|
||||||
|
Control Flow
|
||||||
|
</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<div class="code"><span class="k">if</span> (<span class="v">$x</span> > <span class="s">0</span>) { ... }
|
||||||
|
<span class="k">elseif</span> (<span class="v">$x</span> < <span class="s">0</span>) { ... }
|
||||||
|
<span class="k">else</span> { ... }
|
||||||
|
|
||||||
|
<span class="k">match</span>(<span class="v">$status</span>) {
|
||||||
|
<span class="s">'ok'</span>, <span class="s">'good'</span> => <span class="s">'positiv'</span>,
|
||||||
|
<span class="s">'err'</span> => <span class="s">'negativ'</span>,
|
||||||
|
<span class="k">default</span> => <span class="s">'?'</span>,
|
||||||
|
};
|
||||||
|
|
||||||
|
<span class="k">foreach</span> (<span class="v">$items</span> <span class="k">as</span> <span class="v">$k</span> => <span class="v">$v</span>) { ... }
|
||||||
|
<span class="k">for</span> (<span class="v">$i</span>=<span class="s">0</span>; <span class="v">$i</span><<span class="s">10</span>; <span class="v">$i</span>++) { ... }
|
||||||
|
<span class="k">while</span> (<span class="v">$cond</span>) { ... }</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||||
|
</svg>
|
||||||
|
Enums & Match
|
||||||
|
</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<div class="code"><span class="k">enum</span> <span class="t">Status</span>: <span class="t">string</span> {
|
||||||
|
<span class="k">case</span> <span class="t">Draft</span> = <span class="s">'draft'</span>;
|
||||||
|
<span class="k">case</span> <span class="t">Published</span> = <span class="s">'published'</span>;
|
||||||
|
<span class="k">case</span> <span class="t">Archived</span> = <span class="s">'archived'</span>;
|
||||||
|
|
||||||
|
<span class="k">public function</span> <span class="f">label</span>(): <span class="t">string</span> {
|
||||||
|
<span class="k">return match</span>(<span class="v">$this</span>) {
|
||||||
|
<span class="t">self</span>::<span class="t">Draft</span> => <span class="s">'Entwurf'</span>,
|
||||||
|
<span class="t">self</span>::<span class="t">Published</span> => <span class="s">'Veröffentlicht'</span>,
|
||||||
|
<span class="t">self</span>::<span class="t">Archived</span> => <span class="s">'Archiv'</span>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
Fehler & Try/Catch
|
||||||
|
</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<div class="code"><span class="k">try</span> {
|
||||||
|
<span class="v">$result</span> = <span class="f">risky</span>();
|
||||||
|
} <span class="k">catch</span> (\<span class="t">ValueError</span> <span class="v">$e</span>) {
|
||||||
|
<span class="f">log</span>(<span class="v">$e</span>->getMessage());
|
||||||
|
} <span class="k">catch</span> (\<span class="t">Exception</span> <span class="v">$e</span>) {
|
||||||
|
<span class="k">throw new</span> \<span class="t">AppError</span>(...);
|
||||||
|
} <span class="k">finally</span> {
|
||||||
|
<span class="f">cleanup</span>();
|
||||||
|
}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SPALTE 4 -->
|
||||||
|
<div class="col">
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="9"/>
|
||||||
|
</svg>
|
||||||
|
Ökosystem
|
||||||
|
</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<div class="tiles">
|
||||||
|
<div class="tile">
|
||||||
|
<div class="tile-ico"><svg viewBox="0 0 24 24" fill="white"><polygon points="12,2 22,22 2,22"/></svg></div>
|
||||||
|
<div class="tile-text"><b>Composer</b><span>Paket-Manager</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<div class="tile-ico"><svg viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="10"/></svg></div>
|
||||||
|
<div class="tile-text"><b>Laravel</b><span>Full-Stack</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<div class="tile-ico"><svg viewBox="0 0 24 24" fill="white"><rect x="4" y="4" width="16" height="16"/></svg></div>
|
||||||
|
<div class="tile-text"><b>Symfony</b><span>Enterprise</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<div class="tile-ico"><svg viewBox="0 0 24 24" fill="white"><polygon points="12,2 22,22 2,22"/></svg></div>
|
||||||
|
<div class="tile-text"><b>PHPUnit</b><span>Tests</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<div class="tile-ico"><svg viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="10"/></svg></div>
|
||||||
|
<div class="tile-text"><b>PHPStan</b><span>Static-Analyse</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<div class="tile-ico"><svg viewBox="0 0 24 24" fill="white"><rect x="4" y="4" width="16" height="16"/></svg></div>
|
||||||
|
<div class="tile-text"><b>Shopware</b><span>E-Commerce</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<div class="tile-ico"><svg viewBox="0 0 24 24" fill="white"><polygon points="12,2 22,22 2,22"/></svg></div>
|
||||||
|
<div class="tile-text"><b>WordPress</b><span>CMS</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<div class="tile-ico"><svg viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="10"/></svg></div>
|
||||||
|
<div class="tile-text"><b>Doctrine</b><span>ORM & DBAL</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||||
|
</svg>
|
||||||
|
Idiome vs. Anti-Patterns
|
||||||
|
</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<div class="split">
|
||||||
|
<div class="split-col plus">
|
||||||
|
<h4>Idiomatisch</h4>
|
||||||
|
<ul>
|
||||||
|
<li>strict_types=1</li>
|
||||||
|
<li>Type-Hints überall</li>
|
||||||
|
<li>readonly Properties</li>
|
||||||
|
<li>Composer Autoload</li>
|
||||||
|
<li>PSR-12 Coding-Style</li>
|
||||||
|
<li>match statt switch</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="split-col minus">
|
||||||
|
<h4>Vermeiden</h4>
|
||||||
|
<ul>
|
||||||
|
<li>mysql_* Funktionen</li>
|
||||||
|
<li>extract() von User</li>
|
||||||
|
<li>eval() jeglicher Art</li>
|
||||||
|
<li>Globals ($GLOBALS)</li>
|
||||||
|
<li>@-Suppression</li>
|
||||||
|
<li>include statt require_once</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="foot-item">
|
||||||
|
<b>Projekt starten</b>
|
||||||
|
<code>composer init</code>
|
||||||
|
</div>
|
||||||
|
<div class="foot-item">
|
||||||
|
<b>Server starten</b>
|
||||||
|
<code>php -S localhost:8000</code>
|
||||||
|
</div>
|
||||||
|
<div class="foot-item">
|
||||||
|
<b>Tests</b>
|
||||||
|
<code>vendor/bin/phpunit</code>
|
||||||
|
</div>
|
||||||
|
<div class="foot-item">
|
||||||
|
<b>Static Analysis</b>
|
||||||
|
<code>vendor/bin/phpstan analyse</code>
|
||||||
|
</div>
|
||||||
|
<div class="foot-tag">php.net</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
3921
templates/Referenz/EndGuide.md
Normal file
3921
templates/Referenz/EndGuide.md
Normal file
File diff suppressed because it is too large
Load Diff
2385
templates/Referenz/Guide.md
Normal file
2385
templates/Referenz/Guide.md
Normal file
File diff suppressed because it is too large
Load Diff
395
templates/Referenz/MiniGuide.md
Normal file
395
templates/Referenz/MiniGuide.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
```
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>PHP Mini-Guide</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 18mm 18mm 16mm 18mm;
|
||||||
|
@bottom-center {
|
||||||
|
content: counter(page) " / " counter(pages);
|
||||||
|
font-family: -apple-system, "Segoe UI", sans-serif;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
@bottom-right {
|
||||||
|
content: "PHP Mini-Guide";
|
||||||
|
font-family: -apple-system, "Segoe UI", sans-serif;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--php: #777BB4;
|
||||||
|
--php-dark: #4F5B93;
|
||||||
|
--php-darker: #2C3E66;
|
||||||
|
--ink: #1a1a1a;
|
||||||
|
--muted: #5a6470;
|
||||||
|
--line: #d8dde3;
|
||||||
|
--bg-soft: #f5f5fb;
|
||||||
|
--code-bg: #1e2a3a;
|
||||||
|
--code-fg: #e6e6e6;
|
||||||
|
--plus: #2c8a3e;
|
||||||
|
--minus: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Darkmode — die App setzt data-theme="dark" auf <html>; Druck/PDF bleibt hell */
|
||||||
|
@media screen {
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--ink: #e6e8ee;
|
||||||
|
--muted: #9aa3b2;
|
||||||
|
--line: #2c3038;
|
||||||
|
--bg-soft: #23262e;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] body { background: #15171c; }
|
||||||
|
html[data-theme="dark"] .callout { background: var(--bg-soft); }
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: Charter, "Source Serif Pro", Georgia, serif;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 10.5pt;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== HEAD ===== */
|
||||||
|
.head {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 2pt solid var(--ink);
|
||||||
|
padding-bottom: 4mm;
|
||||||
|
margin-bottom: 5mm;
|
||||||
|
}
|
||||||
|
.head-logo, .head-title, .head-meta {
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.head-logo {
|
||||||
|
width: 20mm;
|
||||||
|
padding-right: 5mm;
|
||||||
|
}
|
||||||
|
.head-logo-box {
|
||||||
|
width: 16mm; height: 16mm;
|
||||||
|
background: linear-gradient(135deg, var(--php) 0%, var(--php-dark) 100%);
|
||||||
|
border-radius: 4mm;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 11pt; font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.head-title h1 {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 20pt;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--php-dark);
|
||||||
|
letter-spacing: -0.5pt;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.head-title h1 .accent { color: var(--php); }
|
||||||
|
.head-title .subtitle {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 1mm;
|
||||||
|
}
|
||||||
|
.head-meta {
|
||||||
|
width: 35mm;
|
||||||
|
text-align: right;
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
.head-meta .badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--ink);
|
||||||
|
color: white;
|
||||||
|
padding: 1.2mm 3mm;
|
||||||
|
border-radius: 1.5mm;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.head-meta .time {
|
||||||
|
display: block;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1pt;
|
||||||
|
margin-top: 1mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== GAP-OPENER ===== */
|
||||||
|
.gap {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-left: 3pt solid var(--php);
|
||||||
|
padding: 3.5mm 5mm;
|
||||||
|
margin: 0 0 6mm 0;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
}
|
||||||
|
.gap > b:first-child {
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--php-dark);
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 8pt;
|
||||||
|
letter-spacing: 1pt;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1.5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SECTIONS ===== */
|
||||||
|
h2 {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--php-dark);
|
||||||
|
margin: 6mm 0 2.5mm 0;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
margin: 4mm 0 1.5mm 0;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin-bottom: 2.5mm;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
p b { color: var(--php-dark); }
|
||||||
|
ul, ol { margin: 1.5mm 0 3mm 6mm; }
|
||||||
|
li { margin-bottom: 1mm; }
|
||||||
|
|
||||||
|
/* ===== CODE ===== */
|
||||||
|
pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--code-fg);
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 3mm 4mm;
|
||||||
|
border-radius: 2mm;
|
||||||
|
margin: 2.5mm 0 3.5mm 0;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: hidden;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.c { color: #6b8aae; font-style: italic; }
|
||||||
|
.k { color: #ff79c6; }
|
||||||
|
.s { color: #f1c40f; }
|
||||||
|
.f { color: #50fa7b; }
|
||||||
|
.t { color: #8be9fd; }
|
||||||
|
.v { color: #ffb86c; }
|
||||||
|
|
||||||
|
code.inline {
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 9pt;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
padding: 0.3mm 1.5mm;
|
||||||
|
border-radius: 1mm;
|
||||||
|
color: var(--php-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CALLOUT ===== */
|
||||||
|
.callout {
|
||||||
|
border-radius: 2mm;
|
||||||
|
padding: 2.5mm 4mm;
|
||||||
|
margin: 3mm 0;
|
||||||
|
font-size: 10pt;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 6mm 1fr;
|
||||||
|
gap: 3mm;
|
||||||
|
}
|
||||||
|
.callout-icon {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 14pt;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.callout-body > b:first-child {
|
||||||
|
font-family: -apple-system, sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 8pt;
|
||||||
|
letter-spacing: 1pt;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
}
|
||||||
|
.callout.tip { background: #e8f4ea; border-left: 3pt solid var(--plus); }
|
||||||
|
.callout.tip .callout-icon, .callout.tip .callout-body > b:first-child { color: var(--plus); }
|
||||||
|
.callout.warn { background: #fdecea; border-left: 3pt solid var(--minus); }
|
||||||
|
.callout.warn .callout-icon, .callout.warn .callout-body > b:first-child { color: var(--minus); }
|
||||||
|
.callout.note { background: var(--bg-soft); border-left: 3pt solid var(--php); }
|
||||||
|
.callout.note .callout-icon, .callout.note .callout-body > b:first-child { color: var(--php-dark); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ===== HEAD ===== -->
|
||||||
|
<header class="head">
|
||||||
|
<div class="head-logo"><div class="head-logo-box">php</div></div>
|
||||||
|
<div class="head-title">
|
||||||
|
<h1><span class="accent">PHP</span> in 15 Minuten</h1>
|
||||||
|
<div class="subtitle">Dein erstes PHP-Programm – Schritt für Schritt</div>
|
||||||
|
</div>
|
||||||
|
<div class="head-meta">
|
||||||
|
<span class="badge">Mini-Guide</span>
|
||||||
|
<span class="time">15 Min · von Null</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ===== GAP-OPENER ===== -->
|
||||||
|
<div class="gap">
|
||||||
|
<b>Frage zum Einstieg</b>
|
||||||
|
PHP läuft hinter rund drei Viertel aller Webseiten – inklusive WordPress, Wikipedia und Facebook. Aber wie sieht PHP-Code überhaupt aus, und wie startet man? In 15 Minuten kannst du dein erstes Programm schreiben.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== SEKTION 1 ===== -->
|
||||||
|
<h2>PHP starten</h2>
|
||||||
|
|
||||||
|
<p>PHP ist eine Programmiersprache, die auf deinem Computer oder einem Webserver läuft. Im Gegensatz zu HTML oder CSS wird PHP nicht im Browser angezeigt – es <i>erzeugt</i> Ausgaben (zum Beispiel HTML), die dann ausgeliefert werden.</p>
|
||||||
|
|
||||||
|
<p>Um anzufangen, brauchst du PHP auf deinem Computer. Auf Mac: <code class="inline">brew install php</code>. Auf Ubuntu: <code class="inline">apt install php8.4-cli</code>. Auf Windows: am einfachsten WSL2 mit Ubuntu darin nutzen.</p>
|
||||||
|
|
||||||
|
<p>PHP-Code lebt in Dateien mit der Endung <code class="inline">.php</code>. Lege eine Datei <code class="inline">hallo.php</code> an mit diesem Inhalt:</p>
|
||||||
|
|
||||||
|
<pre><span class="t"><?php</span>
|
||||||
|
|
||||||
|
<span class="k">echo</span> <span class="s">"Hallo Welt!"</span>;</pre>
|
||||||
|
|
||||||
|
<p>Die erste Zeile <code class="inline"><?php</code> sagt PHP: "ab hier kommt mein Code". Das Wort <code class="inline">echo</code> heißt: "gib das aus, was danach kommt". Strings (also Text) stehen in Anführungszeichen. Jede Anweisung endet mit einem Semikolon.</p>
|
||||||
|
|
||||||
|
<p>Im Terminal ausführen mit:</p>
|
||||||
|
|
||||||
|
<pre>php hallo.php</pre>
|
||||||
|
|
||||||
|
<p>Du siehst "Hallo Welt!" – dein erstes PHP-Programm läuft.</p>
|
||||||
|
|
||||||
|
<!-- ===== SEKTION 2 ===== -->
|
||||||
|
<h2>Variablen</h2>
|
||||||
|
|
||||||
|
<p>Eine <b>Variable</b> ist ein benannter Platz, an dem du einen Wert speicherst. In PHP beginnen Variablen immer mit einem Dollar-Zeichen <code class="inline">$</code>. Das macht sie im Code sofort erkennbar:</p>
|
||||||
|
|
||||||
|
<pre><span class="t"><?php</span>
|
||||||
|
|
||||||
|
<span class="v">$name</span> = <span class="s">"Marek"</span>;
|
||||||
|
<span class="v">$alter</span> = <span class="s">34</span>;
|
||||||
|
<span class="v">$istAktiv</span> = <span class="k">true</span>;
|
||||||
|
|
||||||
|
<span class="k">echo</span> <span class="v">$name</span>;</pre>
|
||||||
|
|
||||||
|
<p>Variablen können verschiedene Arten von Werten enthalten. Die drei wichtigsten:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><b>Strings</b> – Text in Anführungszeichen, z.B. <code class="inline">"Hallo"</code></li>
|
||||||
|
<li><b>Zahlen</b> – ganze Zahlen (<code class="inline">42</code>) oder Kommazahlen (<code class="inline">3.14</code>)</li>
|
||||||
|
<li><b>Wahrheitswerte</b> – <code class="inline">true</code> (wahr) oder <code class="inline">false</code> (falsch)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Mit dem Punkt verbindest du Strings:</p>
|
||||||
|
|
||||||
|
<pre><span class="v">$gruss</span> = <span class="s">"Hallo, "</span> . <span class="v">$name</span> . <span class="s">"!"</span>;
|
||||||
|
<span class="k">echo</span> <span class="v">$gruss</span>; <span class="c">// Hallo, Marek!</span></pre>
|
||||||
|
|
||||||
|
<!-- ===== SEKTION 3 ===== -->
|
||||||
|
<h2>Bedingungen</h2>
|
||||||
|
|
||||||
|
<p>Programme müssen oft entscheiden: "wenn X zutrifft, mach Y, sonst Z". Dafür gibt es <code class="inline">if</code> und <code class="inline">else</code>:</p>
|
||||||
|
|
||||||
|
<pre><span class="v">$alter</span> = <span class="s">17</span>;
|
||||||
|
|
||||||
|
<span class="k">if</span> (<span class="v">$alter</span> >= <span class="s">18</span>) {
|
||||||
|
<span class="k">echo</span> <span class="s">"Du bist erwachsen."</span>;
|
||||||
|
} <span class="k">else</span> {
|
||||||
|
<span class="k">echo</span> <span class="s">"Du bist noch minderjährig."</span>;
|
||||||
|
}</pre>
|
||||||
|
|
||||||
|
<p>Die Klammer hinter <code class="inline">if</code> enthält die Bedingung. Die geschweiften Klammern <code class="inline">{ }</code> umschließen den Code, der ausgeführt wird, wenn die Bedingung wahr ist. <code class="inline">else</code> ist der Block, wenn sie falsch ist.</p>
|
||||||
|
|
||||||
|
<p>Wichtige Vergleichs-Operatoren:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code class="inline">==</code> gleich</li>
|
||||||
|
<li><code class="inline">!=</code> ungleich</li>
|
||||||
|
<li><code class="inline"><</code>, <code class="inline">></code> kleiner, größer</li>
|
||||||
|
<li><code class="inline"><=</code>, <code class="inline">>=</code> kleiner-gleich, größer-gleich</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="callout note">
|
||||||
|
<div class="callout-icon">i</div>
|
||||||
|
<div class="callout-body">
|
||||||
|
<b>Ein Gleichheitszeichen reicht nicht</b>
|
||||||
|
Zum Zuweisen nutzt du <code class="inline">=</code> (ein Gleichheitszeichen). Zum Vergleichen brauchst du <code class="inline">==</code> (zwei). Das ist eine häufige Verwechslung bei Anfängern.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== SEKTION 4 ===== -->
|
||||||
|
<h2>Listen und Schleifen</h2>
|
||||||
|
|
||||||
|
<p>Mehrere Werte fasst du in einer <b>Liste</b> zusammen. In PHP heißen Listen <code class="inline">array</code>:</p>
|
||||||
|
|
||||||
|
<pre><span class="v">$obst</span> = [<span class="s">"Apfel"</span>, <span class="s">"Birne"</span>, <span class="s">"Kirsche"</span>];</pre>
|
||||||
|
|
||||||
|
<p>Über jede Liste kannst du mit <code class="inline">foreach</code> Schritt für Schritt gehen:</p>
|
||||||
|
|
||||||
|
<pre><span class="k">foreach</span> (<span class="v">$obst</span> <span class="k">as</span> <span class="v">$frucht</span>) {
|
||||||
|
<span class="k">echo</span> <span class="v">$frucht</span> . <span class="s">"\n"</span>;
|
||||||
|
}</pre>
|
||||||
|
|
||||||
|
<p>Das gibt "Apfel", "Birne", "Kirsche" untereinander aus. Das <code class="inline">\n</code> ist ein Zeilenumbruch. Die Variable <code class="inline">$frucht</code> bekommt bei jedem Durchlauf den nächsten Wert aus der Liste.</p>
|
||||||
|
|
||||||
|
<p>Listen müssen nicht aus Strings bestehen. Zahlen gehen genauso:</p>
|
||||||
|
|
||||||
|
<pre><span class="v">$zahlen</span> = [<span class="s">10</span>, <span class="s">20</span>, <span class="s">30</span>];
|
||||||
|
<span class="v">$summe</span> = <span class="s">0</span>;
|
||||||
|
|
||||||
|
<span class="k">foreach</span> (<span class="v">$zahlen</span> <span class="k">as</span> <span class="v">$zahl</span>) {
|
||||||
|
<span class="v">$summe</span> = <span class="v">$summe</span> + <span class="v">$zahl</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="k">echo</span> <span class="v">$summe</span>; <span class="c">// 60</span></pre>
|
||||||
|
|
||||||
|
<!-- ===== SEKTION 5 ===== -->
|
||||||
|
<h2>Funktionen</h2>
|
||||||
|
|
||||||
|
<p>Wenn du denselben Code mehrfach brauchst, packst du ihn in eine <b>Funktion</b>. Du gibst der Funktion einen Namen und kannst sie dann beliebig oft aufrufen:</p>
|
||||||
|
|
||||||
|
<pre><span class="k">function</span> <span class="f">begruessen</span>(<span class="v">$name</span>) {
|
||||||
|
<span class="k">echo</span> <span class="s">"Hallo, "</span> . <span class="v">$name</span> . <span class="s">"!\n"</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="f">begruessen</span>(<span class="s">"Marek"</span>);
|
||||||
|
<span class="f">begruessen</span>(<span class="s">"Anna"</span>);
|
||||||
|
<span class="f">begruessen</span>(<span class="s">"Tom"</span>);</pre>
|
||||||
|
|
||||||
|
<p>Die Funktion <code class="inline">begruessen</code> nimmt einen <b>Parameter</b> entgegen (<code class="inline">$name</code>). Beim Aufruf übergibst du den konkreten Wert in den Klammern.</p>
|
||||||
|
|
||||||
|
<p>Funktionen können auch Werte <b>zurückgeben</b>. Dafür gibt es <code class="inline">return</code>:</p>
|
||||||
|
|
||||||
|
<pre><span class="k">function</span> <span class="f">addiere</span>(<span class="v">$a</span>, <span class="v">$b</span>) {
|
||||||
|
<span class="k">return</span> <span class="v">$a</span> + <span class="v">$b</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="v">$ergebnis</span> = <span class="f">addiere</span>(<span class="s">3</span>, <span class="s">5</span>);
|
||||||
|
<span class="k">echo</span> <span class="v">$ergebnis</span>; <span class="c">// 8</span></pre>
|
||||||
|
|
||||||
|
<p>Die Funktion macht ihre Berechnung und liefert das Ergebnis zurück. Du fängst es in einer Variable auf und kannst damit weiterarbeiten.</p>
|
||||||
|
|
||||||
|
<div class="callout tip">
|
||||||
|
<div class="callout-icon">✓</div>
|
||||||
|
<div class="callout-body">
|
||||||
|
<b>Übung macht den Meister</b>
|
||||||
|
Schreibe jetzt selbst ein kleines PHP-Programm. Zum Beispiel: eine Liste deiner Lieblings-Filme, die mit <code class="inline">foreach</code> ausgegeben werden. Oder eine Funktion, die das Doppelte einer Zahl zurückgibt. Praktisch ausprobieren ist der schnellste Weg, PHP zu lernen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
538
templates/Referenz/OnePager.md
Normal file
538
templates/Referenz/OnePager.md
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
```
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>PHP OnePager</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4 landscape; margin: 0; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--php: #777BB4;
|
||||||
|
--php-dark: #4F5B93;
|
||||||
|
--php-darker: #2C3E66;
|
||||||
|
--accent: #F1C40F;
|
||||||
|
--ink: #1a1a1a;
|
||||||
|
--muted: #5a6470;
|
||||||
|
--line: #e5e5e5;
|
||||||
|
--bg-soft: #f5f5fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Darkmode — die App setzt data-theme="dark" auf <html>; Druck/PDF bleibt hell */
|
||||||
|
@media screen {
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--ink: #e6e8ee;
|
||||||
|
--muted: #9aa3b2;
|
||||||
|
--line: #2c3038;
|
||||||
|
--bg-soft: #23262e;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] body { background: #15171c; }
|
||||||
|
html[data-theme="dark"] .callout { background: var(--bg-soft); }
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 9.5pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
width: 297mm;
|
||||||
|
height: 210mm;
|
||||||
|
padding: 9mm 11mm 9mm 11mm;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== HERO ===== */
|
||||||
|
.hero {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 4mm;
|
||||||
|
}
|
||||||
|
.hero-logo, .hero-text, .hero-stats {
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.hero-logo { width: 28mm; padding-right: 5mm; }
|
||||||
|
.hero-logo-box {
|
||||||
|
width: 22mm; height: 22mm;
|
||||||
|
background: linear-gradient(135deg, var(--php) 0%, var(--php-dark) 100%);
|
||||||
|
border-radius: 5mm;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 18pt; font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
letter-spacing: -1pt;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 20pt;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.5pt;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.hero h1 .accent { color: var(--php); }
|
||||||
|
.hero p {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 1mm;
|
||||||
|
}
|
||||||
|
.hero-stats {
|
||||||
|
width: 110mm;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.stats-row {
|
||||||
|
display: inline-table;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.stat-cell {
|
||||||
|
display: table-cell;
|
||||||
|
padding: 0 5mm;
|
||||||
|
text-align: center;
|
||||||
|
border-right: 0.5pt solid var(--line);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.stat-cell:last-child { border-right: none; padding-right: 0; }
|
||||||
|
.stat-cell:first-child { padding-left: 0; }
|
||||||
|
.stat-num {
|
||||||
|
font-size: 14pt;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--php);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 6.5pt;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5pt;
|
||||||
|
margin-top: 1mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Divider ===== */
|
||||||
|
.divider {
|
||||||
|
height: 1.5pt;
|
||||||
|
background: var(--ink);
|
||||||
|
margin-bottom: 4mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== GRID 3-Spalten ===== */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 4mm;
|
||||||
|
margin-bottom: 4mm;
|
||||||
|
}
|
||||||
|
.col { display: flex; flex-direction: column; gap: 4mm; }
|
||||||
|
|
||||||
|
.block { display: flex; flex-direction: column; gap: 1.5mm; }
|
||||||
|
.block h2 {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5pt;
|
||||||
|
color: var(--php);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2mm;
|
||||||
|
border-bottom: 2pt solid var(--php);
|
||||||
|
padding-bottom: 1.2mm;
|
||||||
|
}
|
||||||
|
.block h2 .ico { width: 4mm; height: 4mm; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ===== FEATURE LIST ===== */
|
||||||
|
.feature-list { display: flex; flex-direction: column; gap: 1.5mm; }
|
||||||
|
.feature-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4.5mm 1fr;
|
||||||
|
gap: 1.8mm;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.feature-row .dot {
|
||||||
|
width: 4mm; height: 4mm; margin-top: 0.2mm;
|
||||||
|
background: var(--php);
|
||||||
|
border-radius: 1mm;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 6pt; font-weight: 800; color: white;
|
||||||
|
}
|
||||||
|
.feature-row .text { font-size: 8.5pt; line-height: 1.35; }
|
||||||
|
.feature-row .text b { color: var(--php-dark); }
|
||||||
|
.feature-row code {
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
padding: 0 1mm;
|
||||||
|
border-radius: 1mm;
|
||||||
|
color: var(--php-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CODE ===== */
|
||||||
|
.code {
|
||||||
|
background: #1e2a3a;
|
||||||
|
color: #e6e6e6;
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 7pt;
|
||||||
|
padding: 2.5mm 3mm;
|
||||||
|
border-radius: 1.5mm;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.code .c { color: #6b8aae; font-style: italic; }
|
||||||
|
.code .k { color: #ff79c6; }
|
||||||
|
.code .s { color: #f1c40f; }
|
||||||
|
.code .f { color: #50fa7b; }
|
||||||
|
.code .t { color: #8be9fd; }
|
||||||
|
.code .v { color: #ffb86c; }
|
||||||
|
|
||||||
|
/* ===== ECOSYSTEM ===== */
|
||||||
|
.eco-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5mm;
|
||||||
|
}
|
||||||
|
.eco-item {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-left: 1.5pt solid var(--php);
|
||||||
|
border-radius: 1mm;
|
||||||
|
padding: 1.5mm 2mm;
|
||||||
|
display: flex; align-items: center; gap: 2mm;
|
||||||
|
}
|
||||||
|
.eco-ico {
|
||||||
|
width: 5mm; height: 5mm; flex-shrink: 0;
|
||||||
|
background: var(--php-dark);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.eco-text { display: flex; flex-direction: column; gap: 0.2mm; }
|
||||||
|
.eco-name { font-weight: 700; font-size: 8pt; color: var(--php-dark); }
|
||||||
|
.eco-desc { font-size: 6.5pt; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ===== TYPE TABLE ===== */
|
||||||
|
.types {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5mm;
|
||||||
|
}
|
||||||
|
.type {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
padding: 1.3mm 2mm;
|
||||||
|
border-radius: 1mm;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
}
|
||||||
|
.type b {
|
||||||
|
font-family: "SF Mono", Consolas, monospace;
|
||||||
|
color: var(--php-dark);
|
||||||
|
display: block;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
.type span { color: var(--muted); font-size: 6.8pt; }
|
||||||
|
|
||||||
|
/* ===== SPLIT (composer/comparison) ===== */
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2mm;
|
||||||
|
}
|
||||||
|
.split-col h4 {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4pt;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
}
|
||||||
|
.split-col.modern h4 { color: var(--php); }
|
||||||
|
.split-col.legacy h4 { color: var(--muted); }
|
||||||
|
.split-col ul { list-style: none; }
|
||||||
|
.split-col li {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
padding-left: 3mm;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.8mm;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.split-col.modern li::before {
|
||||||
|
content: "+";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--php);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.split-col.legacy li::before {
|
||||||
|
content: "·";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== FOOTER ===== */
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
left: 11mm;
|
||||||
|
right: 11mm;
|
||||||
|
bottom: 9mm;
|
||||||
|
background: var(--php-darker);
|
||||||
|
color: white;
|
||||||
|
padding: 3mm 5mm;
|
||||||
|
border-radius: 1.5mm;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 4mm;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.footer-text { font-size: 8.5pt; line-height: 1.45; }
|
||||||
|
.footer-text b { color: var(--accent); }
|
||||||
|
.footer-tag {
|
||||||
|
background: var(--php);
|
||||||
|
color: white;
|
||||||
|
padding: 1.5mm 3mm;
|
||||||
|
border-radius: 1.5mm;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<!-- HERO -->
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-logo"><div class="hero-logo-box">php</div></div>
|
||||||
|
<div class="hero-text">
|
||||||
|
<h1><span class="accent">PHP</span> – Server-Sprache des Web</h1>
|
||||||
|
<p>Dynamische Webseiten · seit 1995 · 75% aller Websites · objektorientiert & funktional</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-num">8.4</div>
|
||||||
|
<div class="stat-label">Aktuelle Version</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-num">1995</div>
|
||||||
|
<div class="stat-label">Erstes Release</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-num">75%</div>
|
||||||
|
<div class="stat-label">Aller Websites</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-num">300k+</div>
|
||||||
|
<div class="stat-label">Composer-Pakete</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- MAIN GRID -->
|
||||||
|
<main class="grid">
|
||||||
|
|
||||||
|
<!-- ===== SPALTE 1 ===== -->
|
||||||
|
<div class="col">
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polygon points="12,2 22,8 22,16 12,22 2,16 2,8"/>
|
||||||
|
</svg>
|
||||||
|
Kernkonzepte
|
||||||
|
</h2>
|
||||||
|
<div class="feature-list">
|
||||||
|
<div class="feature-row">
|
||||||
|
<div class="dot">1</div>
|
||||||
|
<div class="text"><b>Server-seitig</b> – läuft auf Webservern, generiert HTML pro Request</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-row">
|
||||||
|
<div class="dot">2</div>
|
||||||
|
<div class="text"><b>Dynamisch typisiert</b> – Typen zur Laufzeit, optional mit Type-Hints prüfbar</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-row">
|
||||||
|
<div class="dot">3</div>
|
||||||
|
<div class="text"><b>OOP & funktional</b> – Klassen, Interfaces, Traits, Enums, First-Class-Funktionen</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-row">
|
||||||
|
<div class="dot">4</div>
|
||||||
|
<div class="text"><b>Composer</b> – moderner Paket-Manager für Dependencies und Autoloading</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M3 6h18M3 12h18M3 18h18"/>
|
||||||
|
</svg>
|
||||||
|
Datentypen
|
||||||
|
</h2>
|
||||||
|
<div class="types">
|
||||||
|
<div class="type"><b>int</b><span>Ganze Zahlen</span></div>
|
||||||
|
<div class="type"><b>float</b><span>Kommazahlen</span></div>
|
||||||
|
<div class="type"><b>string</b><span>Text in '' oder ""</span></div>
|
||||||
|
<div class="type"><b>bool</b><span>true / false</span></div>
|
||||||
|
<div class="type"><b>array</b><span>Liste oder Map</span></div>
|
||||||
|
<div class="type"><b>object</b><span>Klassen-Instanz</span></div>
|
||||||
|
<div class="type"><b>null</b><span>kein Wert</span></div>
|
||||||
|
<div class="type"><b>callable</b><span>Funktion / Closure</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== SPALTE 2 ===== -->
|
||||||
|
<div class="col">
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="16,18 22,12 16,6"/><polyline points="8,6 2,12 8,18"/>
|
||||||
|
</svg>
|
||||||
|
Hello World
|
||||||
|
</h2>
|
||||||
|
<div class="code"><span class="t"><?php</span><br>
|
||||||
|
<span class="k">declare</span>(strict_types=<span class="s">1</span>);<br>
|
||||||
|
<br>
|
||||||
|
<span class="k">function</span> <span class="f">greet</span>(<span class="t">string</span> <span class="v">$name</span>): <span class="t">string</span> {<br>
|
||||||
|
<span class="k">return</span> <span class="s">"Hallo, </span><span class="v">$name</span><span class="s">!"</span>;<br>
|
||||||
|
}<br>
|
||||||
|
<br>
|
||||||
|
<span class="k">echo</span> <span class="f">greet</span>(<span class="s">'Marek'</span>);<br>
|
||||||
|
<span class="c">// Hallo, Marek!</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||||
|
</svg>
|
||||||
|
Moderne vs. Legacy
|
||||||
|
</h2>
|
||||||
|
<div class="split">
|
||||||
|
<div class="split-col modern">
|
||||||
|
<h4>Modern (8.x)</h4>
|
||||||
|
<ul>
|
||||||
|
<li>strict_types</li>
|
||||||
|
<li>Readonly Properties</li>
|
||||||
|
<li>Enums & Match</li>
|
||||||
|
<li>Named Arguments</li>
|
||||||
|
<li>Promoted Constructor</li>
|
||||||
|
<li>Composer + PSR</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="split-col legacy">
|
||||||
|
<h4>Legacy (5.x)</h4>
|
||||||
|
<ul>
|
||||||
|
<li>magic_quotes</li>
|
||||||
|
<li>register_globals</li>
|
||||||
|
<li>mysql_* Funktionen</li>
|
||||||
|
<li>kein Typ-System</li>
|
||||||
|
<li>include/require Chaos</li>
|
||||||
|
<li>Spaghetti-Code</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== SPALTE 3 ===== -->
|
||||||
|
<div class="col">
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="9"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="9"/><line x1="12" y1="15" x2="12" y2="21"/>
|
||||||
|
<line x1="3" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="21" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Ökosystem
|
||||||
|
</h2>
|
||||||
|
<div class="eco-grid">
|
||||||
|
<div class="eco-item">
|
||||||
|
<div class="eco-ico"><svg width="2.8mm" height="2.8mm" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20,6 9,17 4,12"/></svg></div>
|
||||||
|
<div class="eco-text">
|
||||||
|
<span class="eco-name">Laravel</span>
|
||||||
|
<span class="eco-desc">Full-Stack Framework</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="eco-item">
|
||||||
|
<div class="eco-ico"><svg width="2.8mm" height="2.8mm" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20,6 9,17 4,12"/></svg></div>
|
||||||
|
<div class="eco-text">
|
||||||
|
<span class="eco-name">Symfony</span>
|
||||||
|
<span class="eco-desc">Enterprise-Framework</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="eco-item">
|
||||||
|
<div class="eco-ico"><svg width="2.8mm" height="2.8mm" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20,6 9,17 4,12"/></svg></div>
|
||||||
|
<div class="eco-text">
|
||||||
|
<span class="eco-name">Composer</span>
|
||||||
|
<span class="eco-desc">Paket-Manager</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="eco-item">
|
||||||
|
<div class="eco-ico"><svg width="2.8mm" height="2.8mm" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20,6 9,17 4,12"/></svg></div>
|
||||||
|
<div class="eco-text">
|
||||||
|
<span class="eco-name">PHPUnit</span>
|
||||||
|
<span class="eco-desc">Testing-Framework</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="eco-item">
|
||||||
|
<div class="eco-ico"><svg width="2.8mm" height="2.8mm" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20,6 9,17 4,12"/></svg></div>
|
||||||
|
<div class="eco-text">
|
||||||
|
<span class="eco-name">PHPStan</span>
|
||||||
|
<span class="eco-desc">Static Analysis</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="eco-item">
|
||||||
|
<div class="eco-ico"><svg width="2.8mm" height="2.8mm" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20,6 9,17 4,12"/></svg></div>
|
||||||
|
<div class="eco-text">
|
||||||
|
<span class="eco-name">Shopware</span>
|
||||||
|
<span class="eco-desc">E-Commerce-Plattform</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>
|
||||||
|
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
Einsatzgebiete
|
||||||
|
</h2>
|
||||||
|
<div class="feature-list">
|
||||||
|
<div class="feature-row">
|
||||||
|
<div class="dot">W</div>
|
||||||
|
<div class="text"><b>Web-Backends</b> – REST/GraphQL APIs, MVC-Apps mit Laravel oder Symfony</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-row">
|
||||||
|
<div class="dot">C</div>
|
||||||
|
<div class="text"><b>CMS</b> – WordPress, Drupal, TYPO3 für Content-Management</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-row">
|
||||||
|
<div class="dot">E</div>
|
||||||
|
<div class="text"><b>E-Commerce</b> – Shopware, Magento, WooCommerce</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-row">
|
||||||
|
<div class="dot">$</div>
|
||||||
|
<div class="text"><b>CLI-Tools</b> – Symfony Console, Laravel Artisan für Automation</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer-text">
|
||||||
|
<b>Wann PHP wählen?</b> Wenn du schnell ein Web-Backend brauchst, ein riesiges Ökosystem nutzen willst und eine reife Sprache mit großem Talent-Pool suchst. Moderne PHP 8 ist nicht das PHP von 2005.
|
||||||
|
</div>
|
||||||
|
<div class="footer-tag">php.net</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user