diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh index 2b474f3..93ab14d 100755 --- a/scripts/smoke_test.sh +++ b/scripts/smoke_test.sh @@ -46,6 +46,7 @@ sys.path.insert(0, 'web') import http.server, socketserver from serve import Handler port = $PORT +socketserver.TCPServer.allow_reuse_address = True print(f'Serving on http://localhost:{port}', flush=True) with socketserver.TCPServer(('', port), Handler) as httpd: httpd.serve_forever() diff --git a/web/content_types.py b/web/content_types.py new file mode 100644 index 0000000..7eb6602 --- /dev/null +++ b/web/content_types.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +""" +Content-Type (MIME-Type) Mapping für Template-Dateien. + +Bestimmt den richtigen Content-Type basierend auf der Dateierweiterung. +""" + +import os + + +# Mapping von Dateierweiterungen zu Content-Types +EXTENSION_MAP = { + ".json": "application/json", + ".md": "text/plain", + ".html": "text/html", + ".css": "text/css", + ".js": "application/javascript", + ".txt": "text/plain", +} + + +def get_content_type(filepath: str) -> str: + """ + Bestimmt den Content-Type basierend auf der Dateierweiterung. + + Args: + filepath: Pfad zur Datei + + Returns: + Content-Type String, default "text/plain" + """ + ext = os.path.splitext(filepath)[1].lower() + return EXTENSION_MAP.get(ext, "text/plain") diff --git a/web/css/styles.css b/web/css/styles.css new file mode 100644 index 0000000..281e9ad --- /dev/null +++ b/web/css/styles.css @@ -0,0 +1,420 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font); + font-size: 14px; + line-height: 1.5; + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: none; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +.modal-overlay.active { + display: flex; +} + +.modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + max-width: 900px; + width: 100%; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-header h2 { + font-size: 16px; + font-weight: 600; +} + +/* WCAG-konforme Eingabefelder für Edit-Modal (4.5:1 Kontrastminimum) */ +#edit-content-content input, +#edit-content-content textarea, +#edit-modal input, +#edit-modal textarea { + background: #222222; + color: #ffffff; /* Weiß auf Dunkelgrau (#222222) */ + border: 1px solid #cccccc; + border-radius: 4px; + padding: 8px; + font-family: var(--mono); + font-size: 13px; + line-height: 1.4; +} + +#edit-content-content input:focus, +#edit-content-content textarea:focus, +#edit-modal input:focus, +#edit-modal textarea:focus { + outline: 2px solid #2563eb; + outline-offset: 1px; + border-color: #93c5fd; +} + +#edit-content-content input:hover, +#edit-content-content textarea:hover, +#edit-modal input:hover, +#edit-modal textarea:hover { + border-color: #9ca3af; + background: #2d2d2d; /* Dark theme beibehalten */ +} + +.modal-body { + padding: 20px; + overflow-y: auto; +} + +.modal-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 24px; + padding: 4px 8px; + border-radius: 4px; +} + +.modal-close:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.modal-actions { + padding: 16px 20px; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: 16px 24px; + display: flex; + align-items: center; + gap: 16px; +} + +.header h1 { + font-size: 18px; + font-weight: 600; +} + +/* Edit Modal spezifische Eingabefelder - auch Checkboxen mit Kontrast */ +#edit-content-content input[type="checkbox"], +#edit-modal input[type="checkbox"] { + width: auto; + margin-right: 8px; + accent-color: #2563eb; +} + +#edit-content-content input[type="checkbox"] + label, +#edit-modal input[type="checkbox"] + label { + color: #e0e0e0; + font-weight: normal; +} + +.header .badge { + background: var(--accent-light); + color: var(--accent); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.header .actions { + margin-left: auto; + display: flex; + gap: 8px; +} + +.btn { + background: var(--bg-input); + color: var(--text-primary); + border: 1px solid var(--border); + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn:hover { + background: var(--bg-hover); + border-color: var(--border-light); +} + +.btn-primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 24px; +} + +.nav { + display: flex; + gap: 8px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border); + padding-bottom: 16px; +} + +.nav a { + color: var(--text-secondary); + padding: 8px 16px; + border-radius: 4px; + font-size: 13px; + text-decoration: none; +} + +.nav a:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.nav a.active { + color: var(--accent); + background: var(--accent-light); +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.card-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-header h2 { + font-size: 15px; + font-weight: 600; +} + +.card-body { + padding: 20px; +} + +.template-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; +} + +.template-item { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; + transition: border-color 0.15s; +} + +.template-item:hover { + border-color: var(--border-light); +} + +.template-item h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + color: var(--accent-hover); +} + +.template-item .meta { + display: flex; + gap: 12px; + margin-bottom: 12px; + font-size: 12px; + color: var(--text-secondary); +} + +.template-item .meta span { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.template-item p { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 12px; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.template-item .tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.template-item .tag { + background: var(--bg-input); + color: #b0b0b0; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; +} + +.template-item .actions { + display: flex; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.template-item .btn-icon { + padding: 6px 12px; + font-size: 12px; +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} + +.empty-state h3 { + font-size: 16px; + font-weight: 500; + margin-bottom: 8px; + color: var(--text-secondary); +} + +.code-block { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + padding: 16px; + font-family: var(--mono); + font-size: 12px; + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; + margin-top: 12px; + color: var(--text-primary); + max-height: 400px; + overflow-y: auto; +} + +/* Filter bar */ +.filter-bar { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.filter-bar input { + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + flex: 1; + min-width: 200px; +} + +.filter-bar input:focus { + outline: none; + border-color: var(--accent); +} + +.toast { + position: fixed; + bottom: 20px; + right: 20px; + background: var(--bg-card); + border: 1px solid var(--border); + padding: 12px 16px; + border-radius: 6px; + font-size: 13px; + display: none; + z-index: 1001; +} + +.toast.show { + display: block; + animation: fadeIn 0.2s; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Responsive */ +@media (max-width: 768px) { + .container { + padding: 16px; + } + .template-grid { + grid-template-columns: 1fr; + } + .header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + .nav { + overflow-x: auto; + padding-bottom: 8px; + } + .modal { + margin: 10px; + } +} diff --git a/web/css/variables.css b/web/css/variables.css new file mode 100644 index 0000000..ccf8818 --- /dev/null +++ b/web/css/variables.css @@ -0,0 +1,22 @@ +/* CSS-Variablen für das Prompt Templates Design-System */ +:root { + --bg-primary: #0f0f0f; + --bg-secondary: #1a1a1a; + --bg-card: #1e1e1e; + --bg-input: #262626; + --bg-hover: #2a2a2a; + --border: #2e2e2e; + --border-light: #3d3d3d; + --text-primary: #dbdbdb; + --text-secondary: #8b8b8b; + --text-muted: #5e5e5e; + --accent: #e24329; + --accent-hover: #fc6d26; + --accent-light: rgba(226, 67, 41, 0.12); + --green: #2da160; + --red: #dd3e31; + --yellow: #e0a118; + --gray: #737373; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} diff --git a/web/file_ops.py b/web/file_ops.py new file mode 100644 index 0000000..594661a --- /dev/null +++ b/web/file_ops.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +Datei-Ein-/Ausgabe-Operationen für Template-Dateien. + +Stellt sichere Lese- und Schreiboperationen mit Encoding-Handling bereit. +""" + +import os +from pathlib import Path +from typing import Optional + + +def read_file(filepath: str) -> Optional[str]: + """ + Liest eine Datei und gibt den Inhalt als String zurück. + + Args: + filepath: Absoluter Pfad zur Datei + + Returns: + Dateiinhalt als UTF-8 String, oder None bei Fehler + """ + try: + with open(filepath, "r", encoding="utf-8") as f: + return f.read() + except FileNotFoundError: + return None + except Exception: + return None + + +def read_file_binary(filepath: str) -> Optional[bytes]: + """ + Liest eine Datei im Binärmodus. + + Args: + filepath: Absoluter Pfad zur Datei + + Returns: + Dateiinhalt als Bytes, oder None bei Fehler + """ + try: + with open(filepath, "rb") as f: + return f.read() + except FileNotFoundError: + return None + except Exception: + return None + + +def write_file(filepath: str, content: bytes) -> bool: + """ + Schreibt Bytes in eine Datei. Erstellt parent-Verzeichnis falls nötig. + + Args: + filepath: Absoluter Pfad zur Zieldatei + content: Zu schreibende Bytes + + Returns: + True bei Erfolg, False bei Fehler + """ + try: + file_dir = os.path.dirname(filepath) + if not os.path.exists(file_dir) or not os.path.isdir(file_dir): + return False + + with open(filepath, "wb") as f: + f.write(content) + return True + except Exception: + return False + + +def file_exists(filepath: str) -> bool: + """Prüft, ob eine Datei existiert.""" + return os.path.isfile(filepath) + + +def directory_exists(dirpath: str) -> bool: + """Prüft, ob ein Verzeichnis existiert.""" + return os.path.isdir(dirpath) diff --git a/web/handler.py b/web/handler.py new file mode 100644 index 0000000..547426b --- /dev/null +++ b/web/handler.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +HTTP-Handler für den Template-Server. + +Behandelt GET- und PUT-Anfragen für Templates und das Frontend. +""" + +import http.server +import logging +from pathlib import Path +from urllib.parse import urlparse + +# Support für direkte Ausführung und Package-Import +try: + from .path_validator import PathValidator + from .file_ops import read_file_binary, write_file, directory_exists + from .content_types import get_content_type +except ImportError: + from path_validator import PathValidator + from file_ops import read_file_binary, write_file, directory_exists + from content_types import get_content_type + + +logger = logging.getLogger(__name__) + +MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB + + +class Handler(http.server.SimpleHTTPRequestHandler): + """HTTP-Handler für Template-Anfragen.""" + + validator = None # Wird von serve.py gesetzt + directory = None # Wird von serve.py gesetzt + + def __init__(self, *args, directory=None, **kwargs): + super().__init__(*args, directory=directory or self.directory, **kwargs) + + def do_PUT(self): + """Speichert eine Template-Datei.""" + file_path = self.validator.resolve_template_path(urlparse(self.path).path) + if file_path is None: + self.send_error(403, "Forbidden: Invalid path") + return + + # Verzeichnis prüfen - muss existieren + file_dir = Path(file_path).parent + if not directory_exists(str(file_dir)): + self.send_error(404, "Directory not found") + return + + # Content-Type prüfen + content_type = self.headers.get("Content-Type", "") + if "text/plain" not in content_type and not content_type.startswith("text/"): + self.send_error(400, "Unsupported content type") + return + + # Inhalt lesen und speichern + content_length = int(self.headers.get("Content-Length", 0)) + if content_length > MAX_BODY_SIZE: + self.send_error(413, "Request body too large") + return + if content_length <= 0: + self.send_error(400, "No content provided") + return + + try: + file_content = self.rfile.read(content_length) + + if not write_file(file_path, file_content): + raise Exception("write_file returned False") + + response_content_type = get_content_type(file_path) + + self.send_response(200) + self.send_header("Content-type", response_content_type) + self.end_headers() + self.wfile.write(b"File saved successfully") + except Exception as e: + logger.error("Failed to save file: %s", e) + self.send_error(500, f"Failed to save file: {e}") + + def do_GET(self): + """Liefert Dateien aus.""" + parsed_path = urlparse(self.path).path + + # Für Root-Pfad: index.html servieren + if parsed_path == "/" or parsed_path == "/index.html": + self.path = "/index.html" + return super().do_GET() + + # Anfragen für /templates.json oder /templates/* umleiten + file_path = self.validator.resolve_template_path(urlparse(self.path).path) + if file_path is not None: + content_type = get_content_type(file_path) + + file_content = read_file_binary(file_path) + if file_content is None: + self.send_error(404, "File not found") + return + + self.send_response(200) + self.send_header("Content-type", content_type) + self.end_headers() + self.wfile.write(file_content) + return + + return super().do_GET() diff --git a/web/index.html b/web/index.html index f2b15e4..daf4bd7 100644 --- a/web/index.html +++ b/web/index.html @@ -4,450 +4,8 @@