feat: add MCP server with resources, tools, auth, and Traefik routing

- Add backend/app/mcp_server.py with FastMCP server (hms-cms)
- Resources: design-rules, page-structure, sync-status
- Tools: trigger_sync, get_sync_log, set_rentman_token, read_file,
  write_file, create_page, deploy, git_commit, git_status
- Bearer token auth via MCP_AUTH_TOKEN env var
- Mount MCP at /mcp with streamable HTTP transport
- Integrate session manager in FastAPI lifespan
- Add Traefik labels for external /mcp route on hms.media-on.de
- Add git, docker.io, curl to backend Dockerfile
- Mount repo root + docker socket in backend container
- Add mcp>=1.0.0 to requirements.txt
This commit is contained in:
Agent Zero
2026-07-12 15:58:22 +02:00
parent 00c23bc066
commit cd465e0564
124 changed files with 3309 additions and 225 deletions
+2 -2
View File
@@ -2,9 +2,9 @@ FROM python:3.12-slim
WORKDIR /app
# Install system dependencies for asyncpg
# Install system dependencies for asyncpg + git + docker-cli (MCP tools)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && \
gcc libpq-dev git docker.io curl && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
+16
View File
@@ -9,6 +9,7 @@ from app.config import get_settings
from app.database import engine, init_db, async_session
from app.cache import cache
from app.routers import equipment, health, admin, rental_requests, contact
from app.mcp_server import get_mcp_app, mcp_session_manager
from app.services.sync_service import SyncService
from app.services.email_service import EmailService
@@ -16,6 +17,7 @@ logger = logging.getLogger(__name__)
settings = get_settings()
scheduler = AsyncIOScheduler()
_mcp_lifespan_cm = None # MCP session manager context manager
async def run_equipment_sync() -> None:
@@ -38,6 +40,7 @@ async def run_email_retry() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: init DB, start scheduler, connect cache."""
global _mcp_lifespan_cm
# Create tables (for dev/testing; production uses Alembic)
await init_db()
@@ -62,9 +65,19 @@ async def lifespan(app: FastAPI):
scheduler.start()
logger.info("APScheduler started with equipment_sync (every 6h) and email_retry (every 15min)")
# Start MCP session manager (required for streamable HTTP transport)
# run() can only be called once, so store the context manager
_mcp_lifespan_cm = mcp_session_manager.run()
await _mcp_lifespan_cm.__aenter__()
logger.info("MCP session manager started")
yield
# Shutdown
# Stop MCP session manager
if _mcp_lifespan_cm:
await _mcp_lifespan_cm.__aexit__(None, None, None)
_mcp_lifespan_cm = None
scheduler.shutdown(wait=False)
await cache.disconnect()
await engine.dispose()
@@ -92,3 +105,6 @@ app.include_router(health.router)
app.include_router(admin.router)
app.include_router(rental_requests.router)
app.include_router(contact.router)
# MCP Server (streamable HTTP at /mcp)
app.mount("/mcp", get_mcp_app())
+569
View File
@@ -0,0 +1,569 @@
"""MCP Server for HMS CMS provides resources and tools for AI agents.
Resources (read-only):
- design-rules: HMS design system as JSON
- page-structure: React pages and components map
- sync-status: current Rentman sync status
Tools (execute):
- trigger_sync: run Rentman equipment sync
- get_sync_log: fetch sync history
- set_rentman_token: update .env token + restart backend
- read_file: read frontend source file
- write_file: write frontend source file
- create_page: create React page with design validation
- deploy: rebuild + redeploy frontend container
- git_commit: commit all changes
- git_status: show working tree status
"""
import json
import os
import re
import subprocess
import asyncio
import logging
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
from app.database import async_session
from app.services.sync_service import SyncService
from app.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
# ---------------------------------------------------------------------------#
# Paths (mapped via docker-compose volumes)
# ---------------------------------------------------------------------------#
REPO_ROOT = Path("/data/repo")
FRONTEND_ROOT = REPO_ROOT / "frontend"
FRONTEND_SRC = FRONTEND_ROOT / "src"
ENV_FILE = REPO_ROOT / ".env"
# ---------------------------------------------------------------------------#
# Static design rules (derived from frontend/src/index.css)
# ---------------------------------------------------------------------------#
DESIGN_RULES: dict[str, Any] = {
"colors": {
"accent": "#FA5C01",
"bg": "#131313",
"panel": "#1a1a1a",
"surface": "#212121",
"text": "#ffffff",
"text_muted": "#d4d4d4",
"border": "#444444a8",
"secondary": "#656565",
},
"fonts": {"primary": "Inter", "weights": [400, 500, 600, 700, 800]},
"components": {
"hms-btn": "button class with variants: primary, secondary, ghost",
"hms-card": "card container with border, bg, hover effect",
"hms-badge": "badge with variants: primary, gray, success, error",
"hms-input": "input field with focus state",
"hms-chip": "filter chip with active state",
"hms-skeleton": "loading skeleton with shimmer animation",
"hms-speaker-grid": "responsive grid for equipment items",
"hms-gallery": "responsive gallery grid",
"hms-hero": "hero section with bg image and overlay",
},
"layout": {
"max_width": "1280px",
"header_height": "4rem",
"radius": {"sm": 2, "md": 3, "lg": 4, "xl": 4, "full": 9999},
},
"rules": [
"Use Inter font for all text",
"Use #FA5C01 for all accent elements",
"Use hms-* CSS classes, do not invent new ones",
"All text in German",
"Use CSS variables (var(--text), var(--bg), etc.) not hardcoded colors",
"Use Tailwind utility classes for layout, hms-* classes for components",
"Images: use object-cover, lazy loading",
"Buttons: use hms-btn with variant classes",
"Cards: use hms-card class",
"Responsive: sm: breakpoint 640px, lg: breakpoint 1024px",
],
}
# Static page/component info (used when frontend source is not mounted)
_STATIC_PAGES = [
{"path": "/", "file": "src/pages/Index.tsx", "components": ["SpeakerIcon", "ServiceCard"]},
{"path": "/mietkatalog", "file": "src/pages/Mietkatalog.tsx", "components": ["EquipmentCard", "LoadingSkeleton", "ErrorState", "EmptyState"]},
{"path": "/mietkatalog/:id", "file": "src/pages/EquipmentDetail.tsx", "components": ["ErrorState"]},
{"path": "/kontakt", "file": "src/pages/Kontakt.tsx", "components": []},
{"path": "/referenzen", "file": "src/pages/Referenzen.tsx", "components": ["ErrorState", "EmptyState"]},
{"path": "/warenkorb", "file": "src/pages/Warenkorb.tsx", "components": ["EmptyState"]},
{"path": "/admin", "file": "src/pages/Admin.tsx", "components": ["Logo"]},
{"path": "/agb", "file": "src/pages/AGB.tsx", "components": ["LegalContentPage"]},
{"path": "/datenschutz", "file": "src/pages/Datenschutz.tsx", "components": ["LegalContentPage"]},
{"path": "/impressum", "file": "src/pages/Impressum.tsx", "components": ["LegalContentPage"]},
{"path": "/*", "file": "src/pages/ErrorPage.tsx", "components": ["Header", "Footer"]},
]
_STATIC_COMPONENTS = [
{"name": "Header", "file": "src/components/Header.tsx", "props": []},
{"name": "Footer", "file": "src/components/Footer.tsx", "props": []},
{"name": "Logo", "file": "src/components/Logo.tsx", "props": ["size"]},
{"name": "EquipmentCard", "file": "src/components/EquipmentCard.tsx", "props": ["item", "onClick", "onAddToCart"]},
{"name": "LoadingSkeleton", "file": "src/components/LoadingSkeleton.tsx", "props": ["count"]},
{"name": "ErrorState", "file": "src/components/ErrorState.tsx", "props": ["title", "message", "onRetry"]},
{"name": "EmptyState", "file": "src/components/EmptyState.tsx", "props": ["icon", "title", "message", "actionLabel", "onAction"]},
{"name": "ServiceCard", "file": "src/components/ServiceCard.tsx", "props": ["icon", "title", "description"]},
{"name": "SpeakerIcon", "file": "src/components/SpeakerIcon.tsx", "props": ["type"]},
{"name": "LegalContentPage", "file": "src/components/LegalContentPage.tsx", "props": ["title", "content", "loading"]},
{"name": "Layout", "file": "src/components/Layout.tsx", "props": []},
]
# ---------------------------------------------------------------------------#
# FastMCP server instance
# ---------------------------------------------------------------------------#
# streamable_http_path="/" so that when mounted at /mcp the endpoint is /mcp/
# stateless_http=True: each request is independent (no session management)
# Disable DNS rebinding protection for Docker deployment
try:
from mcp.server.fastmcp.settings import TransportSecuritySettings
_ts = TransportSecuritySettings(enable_dns_rebinding_protection=False)
except Exception:
_ts = None
_mcp_kwargs: dict[str, Any] = {
"streamable_http_path": "/",
"stateless_http": True,
}
if _ts is not None:
_mcp_kwargs["transport_security"] = _ts
mcp = FastMCP("hms-cms", **_mcp_kwargs)
# ---------------------------------------------------------------------------#
# Bearer token authentication wrapper
# ---------------------------------------------------------------------------#
_MCP_TOKEN = os.environ.get("MCP_AUTH_TOKEN", "")
class MCPAuthMiddleware:
"""ASGI middleware: validate Authorization: Bearer <token> for /mcp endpoint."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
# If no token configured, allow all (dev mode)
if not _MCP_TOKEN:
return await self.app(scope, receive, send)
# Extract Authorization header
headers = dict(scope.get("headers", []))
auth_header = headers.get(b"authorization", b"").decode()
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if token == _MCP_TOKEN:
return await self.app(scope, receive, send)
# Reject
await send({
"type": "http.response.start",
"status": 401,
"headers": [[b"content-type", b"application/json"], [b"www-authenticate", b"Bearer"]],
})
await send({
"type": "http.response.body",
"body": b'{"error": "Unauthorized: invalid or missing Bearer token"}',
})
def get_mcp_app():
"""Return the MCP ASGI app wrapped with auth middleware."""
return MCPAuthMiddleware(_mcp_streamable_app)
# Expose session manager for lifespan integration in main FastAPI app.
# FastAPI mount() does not call sub-app lifespans, so we must
# manually start/stop the session manager in the main app lifespan.
# Call streamable_http_app() first to lazily initialize the session manager.
_mcp_streamable_app = mcp.streamable_http_app()
mcp_session_manager = mcp._session_manager
# ---------------------------------------------------------------------------#
# Resources (read-only)
# ---------------------------------------------------------------------------#
@mcp.resource("hms://design-rules")
async def design_rules() -> str:
"""Return the HMS design rules as structured JSON."""
return json.dumps(DESIGN_RULES, indent=2, ensure_ascii=False)
@mcp.resource("hms://page-structure")
async def page_structure() -> str:
"""List all React pages and their component usage."""
pages = list(_STATIC_PAGES)
components = list(_STATIC_COMPONENTS)
# Dynamic scan if frontend source is accessible
if FRONTEND_SRC.exists():
pages_dir = FRONTEND_SRC / "pages"
comp_dir = FRONTEND_SRC / "components"
if pages_dir.exists():
dyn_pages: list[dict] = []
for f in sorted(pages_dir.glob("*.tsx")):
content = f.read_text()
imports = re.findall(r"import\s+(\w+)\s+from\s+['\"]\.\.?/", content)
comp_imports = [imp for imp in imports if imp[0].isupper()]
stem = f.stem
if stem == "Index":
path = "/"
elif stem == "ErrorPage":
path = "/*"
elif stem == "EquipmentDetail":
path = "/mietkatalog/:id"
else:
path = f"/{stem.lower()}"
dyn_pages.append({
"path": path,
"file": f"src/pages/{f.name}",
"components": comp_imports,
})
if dyn_pages:
pages = dyn_pages
if comp_dir.exists():
dyn_comps: list[dict] = []
for f in sorted(comp_dir.glob("*.tsx")):
content = f.read_text()
props: list[str] = []
# Match interface Props { ... } or interface XxxProps { ... }
prop_match = re.search(r"interface\s+\w+\s*\{([^}]*)\}", content)
if prop_match:
props = re.findall(r"(\w+)\??\s*:", prop_match.group(1))
dyn_comps.append({
"name": f.stem,
"file": f"src/components/{f.name}",
"props": props,
})
if dyn_comps:
components = dyn_comps
return json.dumps({"pages": pages, "components": components}, indent=2, ensure_ascii=False)
@mcp.resource("hms://sync-status")
async def sync_status() -> str:
"""Return the current sync status (last sync, items, errors)."""
try:
async with async_session() as db:
svc = SyncService(db)
result = await svc.get_last_sync()
return json.dumps(result, indent=2, default=str, ensure_ascii=False)
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
# ---------------------------------------------------------------------------#
# Tools (execute)
# ---------------------------------------------------------------------------#
@mcp.tool()
async def trigger_sync() -> str:
"""Trigger a Rentman equipment sync (like POST /api/admin/sync)."""
try:
async with async_session() as db:
svc = SyncService(db)
result = await svc.run_sync()
return json.dumps(result, indent=2)
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.tool()
async def get_sync_log(page: int = 1, page_size: int = 20) -> str:
"""Return the sync history log.
Args:
page: Page number (1-based)
page_size: Items per page (max 100)
"""
try:
async with async_session() as db:
svc = SyncService(db)
result = await svc.get_sync_log_paginated(page=page, page_size=page_size)
items = []
for log in result["items"]:
items.append({
"id": log.id,
"sync_type": log.sync_type,
"status": log.status,
"started_at": log.started_at.isoformat() if log.started_at else None,
"completed_at": log.completed_at.isoformat() if log.completed_at else None,
"items_processed": log.items_processed,
"items_failed": log.items_failed,
"error_message": log.error_message,
})
return json.dumps({
"items": items,
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
"total_pages": result["total_pages"],
}, indent=2, default=str)
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.tool()
async def set_rentman_token(token: str) -> str:
"""Update the Rentman API Token in .env and restart the backend.
Args:
token: New Rentman API token string
"""
try:
if not ENV_FILE.exists():
return json.dumps({"error": f".env file not found at {ENV_FILE}"})
content = ENV_FILE.read_text()
if "RENTMAN_API_TOKEN=" in content:
content = re.sub(
r"RENTMAN_API_TOKEN=.*",
f"RENTMAN_API_TOKEN={token}",
content,
)
else:
content += f"\nRENTMAN_API_TOKEN={token}\n"
ENV_FILE.write_text(content)
# Restart backend with a short delay so the response can be sent first
async def _restart() -> None:
await asyncio.sleep(2)
try:
subprocess.run(
["docker", "compose", "restart", "backend"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=60,
)
except Exception as exc:
logger.error("Backend restart failed: %s", exc)
asyncio.create_task(_restart())
return json.dumps({
"status": "success",
"message": "Token updated. Backend restart initiated (2s delay).",
})
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.tool()
async def read_file(path: str) -> str:
"""Read a file from the frontend source.
Args:
path: Relative path to the file (relative to frontend/)
"""
try:
full_path = FRONTEND_ROOT / path
if not full_path.exists() or not full_path.is_file():
return json.dumps({"error": f"File not found: {path}"})
# Security: ensure path stays within frontend dir
full_path = full_path.resolve()
if not str(full_path).startswith(str(FRONTEND_ROOT.resolve())):
return json.dumps({"error": "Path traversal blocked"})
return full_path.read_text()
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.tool()
async def write_file(path: str, content: str) -> str:
"""Write a file in the frontend source.
Args:
path: Relative path (relative to frontend/src/)
content: File content to write
"""
try:
full_path = (FRONTEND_SRC / path).resolve()
if not str(full_path).startswith(str(FRONTEND_SRC.resolve())):
return json.dumps({"error": "Path traversal blocked"})
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
return json.dumps({
"status": "success",
"path": str(full_path),
"bytes": len(content),
})
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.tool()
async def create_page(name: str, content: str) -> str:
"""Create a new React page with design rule validation.
Args:
name: Page name (e.g. 'UeberUns' creates src/pages/UeberUns.tsx)
content: Full TSX content of the page
"""
try:
issues: list[str] = []
# Validate: check for non-hms component classes
custom_classes = re.findall(r'className="([^"]*)"', content)
for cls_str in custom_classes:
for word in cls_str.split():
if word.startswith("btn") and not word.startswith("hms-btn"):
issues.append(f"Found '{word}' use 'hms-btn' instead")
if word.startswith("card") and not word.startswith("hms-card"):
issues.append(f"Found '{word}' use 'hms-card' instead")
if word.startswith("badge") and not word.startswith("hms-badge"):
issues.append(f"Found '{word}' use 'hms-badge' instead")
# Validate: hardcoded hex colors (excluding #FA5C01 accent)
hex_colors = re.findall(r"#([0-9A-Fa-f]{6})\b", content)
for hc in hex_colors:
if hc.upper() not in ("FA5C01", "FFFFFF", "000000"):
issues.append(f"Hardcoded color #{hc} use CSS variable instead")
# Validate: non-Inter font-family
font_match = re.search(r'font-family:\s*["\']?(?!Inter)["\']?', content)
if font_match:
issues.append("Non-Inter font detected use Inter for all text")
# Write the file
safe_name = re.sub(r"[^a-zA-Z0-9_-]", "", name)
if not safe_name:
return json.dumps({"error": "Invalid page name"})
filename = f"{safe_name}.tsx"
file_path = FRONTEND_SRC / "pages" / filename
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return json.dumps({
"status": "success",
"file": f"src/pages/{filename}",
"path": str(file_path),
"validation": {
"issues": issues,
"passed": len(issues) == 0,
},
}, indent=2, ensure_ascii=False)
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.tool()
async def deploy() -> str:
"""Build and deploy the frontend.
Runs: docker compose build --no-cache frontend
docker compose up -d --force-recreate frontend
docker compose restart frontend
"""
try:
commands = [
["docker", "compose", "build", "--no-cache", "frontend"],
["docker", "compose", "up", "-d", "--force-recreate", "frontend"],
["docker", "compose", "restart", "frontend"],
]
results = []
for cmd in commands:
r = subprocess.run(
cmd,
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=300,
)
results.append({
"command": " ".join(cmd),
"returncode": r.returncode,
"stdout": r.stdout[-500:] if r.stdout else "",
"stderr": r.stderr[-500:] if r.stderr else "",
})
if r.returncode != 0:
return json.dumps({
"status": "failed",
"step": cmd[2],
"results": results,
}, indent=2)
return json.dumps({"status": "success", "results": results}, indent=2)
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.tool()
async def git_commit(message: str) -> str:
"""Commit all changes with a message.
Args:
message: Git commit message
"""
try:
r = subprocess.run(
["git", "add", "-A"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=30,
)
if r.returncode != 0:
return json.dumps({"error": "git add failed", "stderr": r.stderr})
r = subprocess.run(
["git", "commit", "-m", message],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=30,
)
if r.returncode != 0:
if "nothing to commit" in r.stdout:
return json.dumps({"status": "noop", "message": "Nothing to commit"})
return json.dumps({"error": "git commit failed", "stderr": r.stderr})
return json.dumps({
"status": "success",
"message": message,
"output": r.stdout.strip(),
})
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.tool()
async def git_status() -> str:
"""Return the git working tree status."""
try:
r = subprocess.run(
["git", "status", "--porcelain"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=30,
)
if r.returncode != 0:
return json.dumps({"error": r.stderr})
files = []
for line in r.stdout.strip().split("\n"):
if line:
files.append({
"status": line[:2].strip(),
"file": line[3:].strip(),
})
return json.dumps({"files": files, "clean": len(files) == 0}, indent=2)
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
+1
View File
@@ -16,3 +16,4 @@ pytest>=8.0.0
pytest-asyncio>=0.23.0
pytest-cov>=5.0.0
httpx>=0.27.0
mcp>=1.0.0
+68 -49
View File
@@ -2,36 +2,35 @@ services:
frontend:
build: ./frontend
ports:
- "3000:3000"
- 3000:3000
depends_on:
backend:
condition: service_healthy
environment:
- NUXT_PUBLIC_API_BASE=/api
labels:
- "traefik.enable=true"
- "traefik.http.middlewares.hms-gzip.compress=true"
- "traefik.http.middlewares.hms-redirect.redirectscheme.scheme=https"
- "traefik.http.routers.hms-http.entryPoints=http"
- "traefik.http.routers.hms-http.middlewares=hms-redirect"
- "traefik.http.routers.hms-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)"
- "traefik.http.routers.hms-https.entryPoints=https"
- "traefik.http.routers.hms-https.middlewares=hms-gzip"
- "traefik.http.routers.hms-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)"
- "traefik.http.routers.hms-https.tls=true"
- "traefik.http.routers.hms-https.tls.certresolver=letsencrypt"
- "traefik.http.services.hms.loadbalancer.server.port=3000"
- traefik.enable=true
- traefik.http.middlewares.hms-gzip.compress=true
- traefik.http.middlewares.hms-redirect.redirectscheme.scheme=https
- traefik.http.routers.hms-http.entryPoints=http
- traefik.http.routers.hms-http.middlewares=hms-redirect
- traefik.http.routers.hms-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)
- traefik.http.routers.hms-https.entryPoints=https
- traefik.http.routers.hms-https.middlewares=hms-gzip
- traefik.http.routers.hms-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)
- traefik.http.routers.hms-https.tls=true
- traefik.http.routers.hms-https.tls.certresolver=letsencrypt
- traefik.http.services.hms.loadbalancer.server.port=3000
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
test:
- CMD-SHELL
- curl -sf http://localhost:3000/ || exit 1
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
networks:
- hms-network
- coolify
- hms-network
- coolify
backend:
build: ./backend
depends_on:
@@ -40,68 +39,88 @@ services:
redis:
condition: service_healthy
environment:
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://hms:hms@postgres:5432/hms}
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
- RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN}
- JWT_SECRET=${JWT_SECRET}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_FROM=${SMTP_FROM:-info@hms-licht-ton.de}
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://hms:hms@postgres:5432/hms}
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
- RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN}
- JWT_SECRET=${JWT_SECRET}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_FROM=${SMTP_FROM:-info@hms-licht-ton.de}
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-}
volumes:
- ./images:/data/images
- ./:/data/repo
- ./images:/data/images
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
test:
- CMD
- python
- -c
- import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- hms-network
- hms-network
- coolify
labels:
- traefik.enable=true
- traefik.http.routers.hms-mcp-http.entryPoints=http
- traefik.http.routers.hms-mcp-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/mcp`)
- traefik.http.routers.hms-mcp-http.middlewares=hms-redirect
- traefik.http.routers.hms-mcp-https.entryPoints=https
- traefik.http.routers.hms-mcp-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/mcp`)
- traefik.http.routers.hms-mcp-https.tls=true
- traefik.http.routers.hms-mcp-https.tls.certresolver=letsencrypt
- traefik.http.routers.hms-mcp-https.middlewares=hms-gzip
- traefik.http.services.hms-mcp.loadbalancer.server.port=8000
postgres:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=hms
- POSTGRES_PASSWORD=hms
- POSTGRES_DB=hms
- POSTGRES_USER=hms
- POSTGRES_PASSWORD=hms
- POSTGRES_DB=hms
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hms -d hms"]
test:
- CMD-SHELL
- pg_isready -U hms -d hms
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
networks:
- hms-network
- hms-network
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
- redis_data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test:
- CMD
- redis-cli
- ping
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
networks:
- hms-network
- hms-network
networks:
hms-network:
driver: bridge
coolify:
external: true
volumes:
postgres_data:
redis_data:
postgres_data: null
redis_data: null
+107
View File
@@ -0,0 +1,107 @@
services:
frontend:
build: ./frontend
ports:
- "3000:3000"
depends_on:
backend:
condition: service_healthy
environment:
- NUXT_PUBLIC_API_BASE=/api
labels:
- "traefik.enable=true"
- "traefik.http.middlewares.hms-gzip.compress=true"
- "traefik.http.middlewares.hms-redirect.redirectscheme.scheme=https"
- "traefik.http.routers.hms-http.entryPoints=http"
- "traefik.http.routers.hms-http.middlewares=hms-redirect"
- "traefik.http.routers.hms-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)"
- "traefik.http.routers.hms-https.entryPoints=https"
- "traefik.http.routers.hms-https.middlewares=hms-gzip"
- "traefik.http.routers.hms-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)"
- "traefik.http.routers.hms-https.tls=true"
- "traefik.http.routers.hms-https.tls.certresolver=letsencrypt"
- "traefik.http.services.hms.loadbalancer.server.port=3000"
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
networks:
- hms-network
- coolify
backend:
build: ./backend
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://hms:hms@postgres:5432/hms}
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
- RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN}
- JWT_SECRET=${JWT_SECRET}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_FROM=${SMTP_FROM:-info@hms-licht-ton.de}
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
volumes:
- ./images:/data/images
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- hms-network
postgres:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=hms
- POSTGRES_PASSWORD=hms
- POSTGRES_DB=hms
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hms -d hms"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
networks:
- hms-network
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
networks:
- hms-network
networks:
hms-network:
driver: bridge
coolify:
external: true
volumes:
postgres_data:
redis_data:
+12
View File
@@ -0,0 +1,12 @@
node_modules
.nuxt
.output
dist
.git
.gitignore
*.md
.env
.env.*
playwright-report
test-results
coverage
+21
View File
@@ -0,0 +1,21 @@
# --- Stage 1: Build ---
FROM node:20 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Stage 2: Production ---
FROM node:20-slim AS production
WORKDIR /app
COPY --from=builder /app/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 503 B

After

Width:  |  Height:  |  Size: 503 B

@@ -3,6 +3,7 @@ import type { NuxtConfig } from "nuxt/schema";
export default defineNuxtConfig({
devtools: { enabled: false },
ssr: false,
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt"],
css: ["~/assets/css/main.css"],
app: {
@@ -59,6 +60,7 @@ export default defineNuxtConfig({
},
},
routeRules: {
"/": { ssr: false },
"/mietkatalog": { ssr: true },
"/mietkatalog/**": { ssr: true },
"/warenkorb": { ssr: false },
@@ -76,6 +78,7 @@ export default defineNuxtConfig({
nitro: {
compressPublicAssets: true,
routeRules: {
"/": { ssr: false },
"/api/**": {
proxy: "http://backend:8000/api/**",
},
+34
View File
@@ -0,0 +1,34 @@
{
"name": "hms-licht-ton-frontend",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"typecheck": "nuxt typecheck",
"lint": "eslint .",
"test": "vitest run --reporter verbose",
"test:e2e": "playwright test"
},
"dependencies": {
"@pinia/nuxt": "^0.11.3",
"nuxt": "^3.14.0",
"pinia": "^3.0.4",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@nuxt/test-utils": "^3.14.0",
"@nuxtjs/tailwindcss": "^6.12.0",
"@playwright/test": "^1.48.0",
"@vue/test-utils": "^2.4.5",
"happy-dom": "^15.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vitest": "^3.2.0",
"vue-tsc": "^2.1.0"
}
}
+20
View File
@@ -0,0 +1,20 @@
<template>
<div>
<section class="py-20 sm:py-32" :style="{ background: 'var(--bg)' }">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-2xl">
<h1 class="text-4xl sm:text-5xl font-bold mb-6" style="color: var(--text)">
Professionelle Technik<br><span style="color: var(--color-accent)">fuer Ihre Veranstaltung</span>
</h1>
<p class="text-lg mb-8" style="color: var(--text-muted)">
HMS Licht & Ton - Veranstaltungstechnik aus Leipheim / Ellzee.
</p>
<NuxtLink to="/mietkatalog" class="hms-btn hms-btn-primary text-base px-8 py-4">Mietkatalog oeffnen</NuxtLink>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
</script>

Before

Width:  |  Height:  |  Size: 503 B

After

Width:  |  Height:  |  Size: 503 B

+65
View File
@@ -0,0 +1,65 @@
import type { Config } from "tailwindcss";
export default <Config>{
content: [
"./components/**/*.{vue,js,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./app.vue",
"./error.vue",
],
theme: {
extend: {
colors: {
bg: "#131313",
panel: "#1a1a1a",
surface: "#212121",
row: "#272727",
card: "#2d2d2d",
"border-default": "#444444a8",
secondary: "#656565",
primary: "#737a81",
text: "#ffffff",
"text-muted": "#d4d4d4",
accent: {
DEFAULT: "#FA5C01",
hover: "#d4581a",
light: "rgba(236, 105, 37, 0.08)",
border: "rgba(236, 105, 37, 0.25)",
dark: "#b8461a",
},
success: "#4ade80",
"success-bg": "rgba(74, 222, 128, 0.08)",
error: "#f87171",
"error-bg": "rgba(248, 113, 113, 0.08)",
warning: "#fbbf24",
info: "#60a5fa",
},
borderRadius: {
sm: "2px",
md: "3px",
lg: "4px",
xl: "4px",
full: "9999px",
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
boxShadow: {
sm: "0 1px 2px 0 rgb(0 0 0 / 0.3)",
md: "0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)",
lg: "0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)",
xl: "0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3)",
},
transitionDuration: {
fast: "150ms",
base: "250ms",
slow: "400ms",
},
maxWidth: {
"7xl": "1280px",
},
},
},
plugins: [],
};
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
-9
View File
@@ -1,12 +1,3 @@
node_modules
.nuxt
.output
dist
.git
.gitignore
*.md
.env
.env.*
playwright-report
test-results
coverage
+9 -16
View File
@@ -1,21 +1,14 @@
# --- Stage 1: Build ---
FROM node:20 AS builder
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . .
RUN npm run build
# --- Stage 2: Production ---
FROM node:20-slim AS production
WORKDIR /app
COPY --from=builder /app/.output ./.output
# Stage 2: Serve with Nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
CMD ["nginx", "-g", "daemon off;"]
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="theme-color" content="#131313" />
<meta name="description" content="HMS Licht & Ton Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport von Tontechnik und Lichttechnik." />
<meta name="author" content="Hammerschmidt u. Mössle GbR" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
<title>HMS Licht & Ton Veranstaltungstechnik</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": "HMS Licht & Ton Hammerschmidt u. Mössle GbR",
"description": "Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport.",
"address": {
"@type": "PostalAddress",
"streetAddress": "Grockelhofen 10",
"addressLocality": "Leipheim",
"postalCode": "89340",
"addressCountry": "DE"
},
"telephone": "+498221204433",
"email": "info@hms-licht-ton.de",
"url": "https://hms.media-on.de",
"areaServed": "Süddeutschland",
"knowsAbout": ["Tontechnik", "Lichttechnik", "Rigging", "Veranstaltungstechnik"]
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+14 -22
View File
@@ -1,34 +1,26 @@
{
"name": "hms-licht-ton-frontend",
"name": "hms-licht-ton-frontend-react",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"typecheck": "nuxt typecheck",
"lint": "eslint .",
"test": "vitest run --reporter verbose",
"test:e2e": "playwright test"
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@pinia/nuxt": "^0.11.3",
"nuxt": "^3.14.0",
"pinia": "^3.0.4",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@nuxt/test-utils": "^3.14.0",
"@nuxtjs/tailwindcss": "^6.12.0",
"@playwright/test": "^1.48.0",
"@vue/test-utils": "^2.4.5",
"happy-dom": "^15.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vitest": "^3.2.0",
"vue-tsc": "^2.1.0"
"vite": "^5.4.0"
}
}
-121
View File
@@ -1,121 +0,0 @@
<template>
<div>
<!-- Hero with background image -->
<section class="hms-hero py-20 sm:py-32" aria-labelledby="hero-title">
<div class="hms-hero-bg" style="background-image: url('https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=1920&q=80')"></div>
<div class="hms-hero-overlay"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div class="max-w-2xl">
<span class="hms-badge hms-badge-primary mb-4">Seit 2003 · Über 20 Jahre Veranstaltungstechnik</span>
<h1 id="hero-title" class="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-6" style="color: var(--text)">
Professionelle Technik<br><span style="color: var(--color-accent)">für Ihre Veranstaltung</span>
</h1>
<p class="text-lg sm:text-xl mb-8 max-w-xl" style="color: var(--text-muted)">
Von der Firmenevent-Beschallung bis zur Open-Air-Großproduktion: HMS Licht & Ton liefert Tontechnik, Lichttechnik, Rigging und Komplettlösungen aus Leipheim und Ellzee.
</p>
<div class="flex flex-col sm:flex-row gap-4">
<button @click="navigate('/mietkatalog')" class="hms-btn hms-btn-primary text-base px-8 py-4">
Mietkatalog öffnen
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
</button>
<button @click="navigate('/kontakt')" class="hms-btn hms-btn-secondary text-base px-8 py-4">Beratung anfragen</button>
</div>
</div>
</div>
</section>
<!-- Speaker Grid -->
<section class="py-12 sm:py-16" :style="{ background: 'var(--bg)' }" aria-labelledby="speaker-title">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-8">
<span class="text-sm font-semibold uppercase tracking-wider" style="color: var(--color-accent)">Equipment-Highlights</span>
<h2 id="speaker-title" class="text-2xl sm:text-3xl font-bold mt-1" style="color: var(--text)">Lautsprecher & Strahler aus unserem Mietlager</h2>
</div>
<div class="hms-speaker-grid">
<div v-for="sp in speakers" :key="sp.name" class="hms-speaker-item" @click="navigate('/mietkatalog')" role="button" tabindex="0" @keydown.enter="navigate('/mietkatalog')">
<SpeakerIcon :type="sp.type" />
<div class="hms-speaker-name">{{ sp.name }}</div>
<div class="hms-speaker-type">{{ sp.category }}</div>
</div>
</div>
</div>
</section>
<!-- Über uns -->
<section class="py-16 sm:py-20" :style="{ background: 'var(--panel)' }" aria-labelledby="about-title">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<div>
<span class="text-sm font-semibold uppercase tracking-wider mb-2" style="color: var(--color-accent)">Unternehmen</span>
<h2 id="about-title" class="text-3xl sm:text-4xl font-bold mb-6" style="color: var(--text)">Ihr Technik-Partner mit Erfahrung</h2>
<p class="leading-relaxed mb-4" style="color: var(--text-muted)">Die <strong style="color: var(--text)">Hammerschmidt u. Mössle GbR</strong> ist seit 2003 Ihr zuverlässiger Partner für Veranstaltungstechnik in der Region Leipheim / Ellzee und im gesamten süddeutschen Raum.</p>
<p class="leading-relaxed mb-4" style="color: var(--text-muted)">Mit einem Mietlager von über 1.000 Geräten, einer eigenen Werkstatt und erfahrenem Personal realisieren wir Veranstaltungen jeder Größenordnung.</p>
<div class="grid grid-cols-3 gap-4 mt-8">
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">20+</div><div class="text-sm" style="color: var(--secondary)">Jahre Erfahrung</div></div>
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">1.000+</div><div class="text-sm" style="color: var(--secondary)">Geräte im Lager</div></div>
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">2</div><div class="text-sm" style="color: var(--secondary)">Standorte</div></div>
</div>
</div>
<div class="relative">
<div class="aspect-[4/3] rounded-lg overflow-hidden shadow-xl">
<img src="https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&q=80" alt="Veranstaltungstechnik Setup" loading="lazy" class="w-full h-full object-cover" />
</div>
</div>
</div>
</div>
</section>
<!-- Leistungen -->
<section class="py-16 sm:py-20" :style="{ background: 'var(--bg)' }" aria-labelledby="services-title">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<span class="text-sm font-semibold uppercase tracking-wider mb-2" style="color: var(--color-accent)">Leistungen</span>
<h2 id="services-title" class="text-3xl sm:text-4xl font-bold" style="color: var(--text)">Von der Vermietung bis zur Komplettbetreuung</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<ServiceCard v-for="s in services" :key="s.title" :icon="s.icon" :title="s.title" :description="s.description" />
</div>
</div>
</section>
<!-- CTA -->
<section class="py-16 sm:py-20" :style="{ background: 'var(--panel)' }" aria-labelledby="rental-title">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="rounded-lg p-8 sm:p-12 lg:p-16 text-center" style="background: var(--bg); border: 1px solid var(--border)">
<h2 id="rental-title" class="text-3xl sm:text-4xl font-bold mb-4" style="color: var(--text)">Online-Mietkatalog</h2>
<p class="max-w-2xl mx-auto mb-8" style="color: var(--text-muted)">Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage direkt online zusammen.</p>
<button @click="navigate('/mietkatalog')" class="hms-btn hms-btn-primary text-base px-8 py-4">
Katalog durchsuchen
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
</button>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
const services = [
{ icon: '🔊', title: 'Vermietung', description: 'Lautsprecher, Mischpulte, Lichtanlagen, Rigging und Komplett-Setups aus unserem 1.000+ Geräte umfassenden Mietlager in Ellzee. Geprüftes Equipment, sofort einsatzbereit.' },
{ icon: '🛒', title: 'Verkauf', description: 'Neu- und Gebrauchtgeräte führender Hersteller mit Beratung, Inbetriebnahme und Service. Wir liefern nicht nur wir konfigurieren Ihre Anlage einsatzfertig.' },
{ icon: '👷', title: 'Personal', description: 'Qualifizierte Tontechniker, Lichttechniker und Rigger für Aufbau, Betrieb und Abbau. Mit TÜV-geprüfter Ausbildung und umfangreicher Praxiserfahrung.' },
{ icon: '🚚', title: 'Transport', description: 'Eigene Transportfahrzeuge für sichere An- und Ablieferung. Inklusive Verladekonzept, Positionierung und Rückholung kundengerecht terminiert.' },
{ icon: '📦', title: 'Lagerung', description: 'Klimatisierte und trockene Lagerung für Equipment und Veranstaltungsbestände. Mit Bestandsmanagement und regelmäßiger Funktionsprüfung.' },
{ icon: '🔧', title: 'Werkstatt', description: 'Herstellerübergreifende Reparatur und Wartung in unserer eigenen Werkstatt. Regelmäßige DGUV-Prüfungen und Kalibrierung für Ihren sicheren Betrieb.' },
{ icon: '⚡', title: 'Installation', description: 'Festinstallationen in Schaufenstern, Lokalen und Veranstaltungsstätten. Von der Planung über die Elektroinstallation bis zur Abnahme alles aus einer Hand.' },
{ icon: '📅', title: 'Booking', description: 'Vermittlung von Künstlern und Technik-Personal für Ihre Veranstaltung. Mit unserem branchenweiten Netzwerk finden wir die passenden Professionals für Ihr Event.' }
]
const speakers = [
{ type: 'linearray', name: 'L-Acoustics K2', category: 'Line Array' },
{ type: 'subwoofer', name: 'L-Acoustics KS28', category: 'Subwoofer' },
{ type: 'pointsource', name: 'd&b T10', category: 'Point Source' },
{ type: 'column', name: 'd&b KSL8', category: 'Column Array' },
{ type: 'monitor', name: 'd&b M4', category: 'Monitor' },
{ type: 'movinghead', name: 'Robe Pointe', category: 'Moving Head' }
]
function navigate(route: string) {
navigateTo(route)
if (import.meta.client) window.scrollTo(0, 0)
}
</script>
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<path fill="#ffffff" d="M166.266,172.872h-37.687v-60.718H74.226v60.718H36.539V26.956h37.687v56.335h54.354V26.956h37.687V172.872z" />
<rect x="23.598" y="15.343" fill="none" stroke="#FA5C01" stroke-width="15.1525" stroke-miterlimit="10" width="155.612" height="168.874" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+33
View File
@@ -0,0 +1,33 @@
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Index from './pages/Index';
import Mietkatalog from './pages/Mietkatalog';
import EquipmentDetail from './pages/EquipmentDetail';
import Kontakt from './pages/Kontakt';
import Warenkorb from './pages/Warenkorb';
import Admin from './pages/Admin';
import Referenzen from './pages/Referenzen';
import Impressum from './pages/Impressum';
import Datenschutz from './pages/Datenschutz';
import AGB from './pages/AGB';
import ErrorPage from './pages/ErrorPage';
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Index />} />
<Route path="/mietkatalog" element={<Mietkatalog />} />
<Route path="/mietkatalog/:id" element={<EquipmentDetail />} />
<Route path="/kontakt" element={<Kontakt />} />
<Route path="/warenkorb" element={<Warenkorb />} />
<Route path="/admin" element={<Admin />} />
<Route path="/referenzen" element={<Referenzen />} />
<Route path="/impressum" element={<Impressum />} />
<Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/agb-vermietung" element={<AGB />} />
<Route path="*" element={<ErrorPage />} />
</Route>
</Routes>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { apiPost } from './client';
export async function adminLogin(username: string, password: string): Promise<{ token: string }> {
return apiPost<{ token: string }>('/admin/login', { username, password });
}
export async function adminSync(token: string): Promise<{ status: string; message: string }> {
return apiPost<{ status: string; message: string }>('/admin/sync', undefined, token);
}
+54
View File
@@ -0,0 +1,54 @@
const API_BASE = '/api';
export async function apiGet<T>(url: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
const query = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value));
}
}
}
const qs = query.toString();
const fullUrl = `${API_BASE}${url}${qs ? '?' + qs : ''}`;
const res = await fetch(fullUrl, {
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
}
export async function apiPost<T>(url: string, body?: unknown, token?: string): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${url}`, {
method: 'POST',
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
}
export async function apiPut<T>(url: string, body?: unknown, token?: string): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${url}`, {
method: 'PUT',
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
}
export async function apiDelete<T>(url: string, token?: string): Promise<T> {
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${url}`, {
method: 'DELETE',
headers,
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
}
+30
View File
@@ -0,0 +1,30 @@
import { apiGet } from './client';
import type { EquipmentItem, EquipmentDetail, PaginatedEquipment, SortOption } from '../types';
export async function fetchEquipment(params: {
search?: string;
category?: string;
sort?: SortOption;
page?: number;
page_size?: number;
}): Promise<PaginatedEquipment> {
return apiGet<PaginatedEquipment>('/equipment', {
search: params.search || undefined,
category: params.category || undefined,
sort: params.sort || 'name_asc',
page: params.page || 1,
page_size: params.page_size || 24,
});
}
export async function fetchEquipmentDetail(id: number | string): Promise<EquipmentDetail> {
return apiGet<EquipmentDetail>(`/equipment/${id}`);
}
export async function fetchCategories(): Promise<string[]> {
return apiGet<string[]>('/equipment/categories');
}
export async function fetchEquipmentById(id: number | string): Promise<EquipmentItem> {
return apiGet<EquipmentItem>(`/equipment/${id}`);
}
+18
View File
@@ -0,0 +1,18 @@
interface Props {
icon?: string;
title: string;
message: string;
actionLabel?: string;
onAction?: () => void;
}
export default function EmptyState({ icon = '🔍', title, message, actionLabel, onAction }: Props) {
return (
<div className="text-center py-16 px-4" role="status">
<div className="text-6xl mb-4" aria-hidden="true">{icon}</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
<p className="max-w-md mx-auto mb-6" style={{ color: 'var(--secondary)' }}>{message}</p>
{actionLabel && <button onClick={onAction} className="hms-btn hms-btn-primary">{actionLabel}</button>}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { EquipmentItem } from '../types';
interface Props {
item: EquipmentItem & { code?: string; image?: string };
onClick: (item: any) => void;
onAddToCart: (item: any) => void;
}
export default function EquipmentCard({ item, onClick, onAddToCart }: Props) {
return (
<div className="hms-eq-card" onClick={() => onClick(item)} role="article" tabIndex={0} onKeyDown={e => e.key === 'Enter' && onClick(item)}>
<div className="aspect-[4/3] flex items-center justify-center relative overflow-hidden" style={{ background: 'var(--surface)' }}>
{item.image ? (
<img src={item.image} alt={item.name} loading="lazy" className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="text-4xl" style={{ color: 'var(--secondary)' }}>📦</div>
)}
{item.category && <span className="hms-badge hms-badge-primary absolute top-3 left-3">{item.category}</span>}
</div>
<div className="p-4">
<h3 className="font-semibold text-sm mb-1 truncate" style={{ color: 'var(--text)' }}>{item.name}</h3>
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--secondary)' }}>{item.description}</p>
<div className="flex items-center justify-between">
<span className="text-xs" style={{ color: 'var(--secondary)' }}>{item.code}</span>
<button onClick={e => { e.stopPropagation(); onAddToCart(item); }} className="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style={{ color: 'var(--color-accent)' }} aria-label={item.name + ' zum Warenkorb hinzufügen'}>+ Hinzufügen</button>
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
interface Props {
title?: string;
message: string;
onRetry: () => void;
}
export default function ErrorState({ title = 'Ein Fehler ist aufgetreten', message, onRetry }: Props) {
return (
<div className="text-center py-16 px-4" role="alert">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ background: 'var(--color-error-bg)', color: 'var(--color-error)' }}>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
<p className="max-w-md mx-auto mb-6" style={{ color: 'var(--secondary)' }}>{message}</p>
<button onClick={onRetry} className="hms-btn hms-btn-primary">Erneut versuchen</button>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { Link } from 'react-router-dom';
import Logo from './Logo';
export default function Footer() {
const year = new Date().getFullYear();
return (
<footer className="hms-footer mt-16" role="contentinfo">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
<div>
<div className="flex items-center gap-2 mb-4" style={{ color: 'var(--text)' }}>
<Logo size={36} />
<div><div className="font-bold text-sm">HMS Licht & Ton</div><div className="text-xs" style={{ color: 'var(--secondary)' }}>Veranstaltungstechnik</div></div>
</div>
<p className="text-sm leading-relaxed" style={{ color: 'var(--secondary)' }}>Hammerschmidt u. Mössle GbR seit über 20 Jahren Ihr Partner für professionelle Veranstaltungstechnik in der Region Leipheim / Ellzee.</p>
</div>
<div>
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Navigation</h3>
<ul className="space-y-2 text-sm">
<li><Link to="/" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Home</Link></li>
<li><Link to="/referenzen" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Referenzen</Link></li>
<li><Link to="/mietkatalog" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Mietkatalog</Link></li>
<li><Link to="/kontakt" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Kontakt</Link></li>
</ul>
</div>
<div>
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Kontakt</h3>
<ul className="space-y-2 text-sm" style={{ color: 'var(--secondary)' }}>
<li>Grockelhofen 10, 89340 Leipheim</li>
<li><a href="tel:+498221204433" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">+49 (0) 8221 / 204433</a></li>
<li><a href="mailto:info@hms-licht-ton.de" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">info@hms-licht-ton.de</a></li>
<li className="flex gap-3 pt-2">
<a href="https://facebook.com" target="_blank" rel="noopener" aria-label="Facebook" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]"><svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
<a href="https://instagram.com" target="_blank" rel="noopener" aria-label="Instagram" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]"><svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg></a>
</li>
</ul>
</div>
<div>
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Rechtliches</h3>
<ul className="space-y-2 text-sm">
<li><Link to="/impressum" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Impressum</Link></li>
<li><Link to="/datenschutz" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">DSGVO</Link></li>
<li><Link to="/agb-vermietung" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">AGB Vermietung</Link></li>
<li><Link to="/admin" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)] text-xs">Admin-Login</Link></li>
</ul>
</div>
</div>
<div className="border-t mt-8 pt-6 text-center text-xs" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}>
© {year} Hammerschmidt u. Mössle GbR · Veranstaltungstechnik · Leipheim / Ellzee
</div>
</div>
</footer>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import Logo from './Logo';
const navItems = [
{ label: 'Home', to: '/' },
{ label: 'Referenzen', to: '/referenzen' },
{ label: 'Mietkatalog', to: '/mietkatalog' },
{ label: 'Kontakt', to: '/kontakt' }
];
export default function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const location = useLocation();
function isActive(to: string): boolean {
if (to === '/') return location.pathname === '/';
return location.pathname.startsWith(to);
}
return (
<header className="hms-header" role="banner">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between" style={{ height: 'var(--header-height)' }}>
<Link to="/" className="flex items-center gap-2" style={{ color: 'var(--text)' }} aria-label="HMS Licht & Ton Startseite">
<Logo size={40} />
<div className="hidden sm:block leading-tight">
<div className="font-bold text-base" style={{ color: 'var(--text)' }}>HMS Licht & Ton</div>
<div className="text-xs" style={{ color: 'var(--secondary)' }}>Veranstaltungstechnik</div>
</div>
</Link>
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="Hauptnavigation">
{navItems.map(item => (
<Link key={item.to} to={item.to}
className="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
style={isActive(item.to) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }}
aria-current={isActive(item.to) ? 'page' : undefined}>
{item.label}
</Link>
))}
<Link to="/warenkorb" className="hms-btn hms-btn-primary text-sm ml-2 px-4 py-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
Warenkorb
</Link>
</nav>
<button onClick={() => setMobileMenuOpen(!mobileMenuOpen)} className="md:hidden p-2 rounded-lg" style={{ color: 'var(--text-muted)' }} aria-expanded={mobileMenuOpen} aria-label="Menü öffnen/schließen">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{!mobileMenuOpen ?
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"/> :
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/>
}
</svg>
</button>
</div>
</div>
{mobileMenuOpen && (
<nav id="mobile-menu" className="md:hidden border-t" style={{ borderColor: 'var(--border)', background: 'var(--panel)' }} role="navigation" aria-label="Mobile Navigation">
<div className="px-4 py-3 space-y-1">
{navItems.map(item => (
<Link key={item.to} to={item.to} onClick={() => setMobileMenuOpen(false)}
className="hms-nav-btn block w-full text-left px-4 py-3 rounded-lg text-sm font-medium"
style={isActive(item.to) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }}>
{item.label}
</Link>
))}
<Link to="/warenkorb" onClick={() => setMobileMenuOpen(false)} className="block w-full text-left px-4 py-3 rounded-lg text-sm font-medium text-white" style={{ background: 'var(--color-accent)' }}>🛒 Warenkorb</Link>
</div>
</nav>
)}
</header>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Outlet } from 'react-router-dom';
import Header from './Header';
import Footer from './Footer';
export default function Layout() {
return (
<div className="min-h-screen flex flex-col">
<a href="#main-content" className="skip-link">Zum Hauptinhalt springen</a>
<Header />
<main id="main-content" className="flex-1" role="main">
<Outlet />
</main>
<Footer />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More