19 Commits

Author SHA1 Message Date
Agent Zero 489419a7d4 fix: add Traefik service port label for frontend (port 3000)
Coolify generates router labels but not the service port label for
docker-compose services. Without this, Traefik gets 502 because it
doesn't know which port to forward traffic to.
2026-07-15 13:20:11 +02:00
Agent Zero f815e64499 fix: add coolify external network to frontend for Traefik routing
Coolify does not automatically add the coolify network to docker-compose services.
The frontend needs to be on the coolify network for Traefik to route traffic to it.
2026-07-15 13:14:03 +02:00
Agent Zero e5606c64c5 fix: remove custom Traefik labels and port mapping for Coolify deployment
- Remove conflicting Traefik labels that caused redirect loop with Coolify auto-generated labels
- Remove port mapping (Coolify handles routing via Traefik)
- Remove external coolify network (Coolify adds it automatically)
- Remove ./ volume mount from backend (incompatible with Coolify build context)
2026-07-15 13:09:52 +02:00
Agent Zero a08bdaa3b1 Revert "chore: add equipment images and docker-compose backup"
This reverts commit 36a1ad0b84.
2026-07-14 22:24:28 +02:00
Agent Zero 36a1ad0b84 chore: add equipment images and docker-compose backup 2026-07-14 22:23:40 +02:00
Agent Zero 80cb971308 fix: disable MCP DNS rebinding protection for Docker/proxy deployment (fixes 421 Invalid Host header) 2026-07-13 10:07:23 +02:00
Agent Zero 6483453437 feat: reintegrate MCP server with proper session manager lifecycle, nginx /mcp proxy, docker.sock mount 2026-07-13 03:42:13 +02:00
Agent Zero 1221350ed6 fix: remove coolify network from backend to prevent DNS conflict with crm-postgres; remove MCP Traefik labels and docker.sock mount 2026-07-13 03:00:12 +02:00
Agent Zero 620f7b3153 fix: remove MCP integration from main.py to restore backend stability; update docker-compose volumes and labels 2026-07-13 02:48:41 +02:00
Agent Zero cd465e0564 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
2026-07-12 15:58:22 +02:00
Agent Zero 00c23bc066 fix: unify accent color to #FA5C01 (original logo orange) 2026-07-11 21:07:14 +02:00
Agent Zero 5f67cc9140 fix: replace passlib with direct bcrypt for bcrypt 5.0 compatibility 2026-07-11 19:38:47 +02:00
Agent Zero a6375567b0 Merge branch 'feature/T04-rentman-integration'
# Conflicts:
#	backend/app/services/sync_service.py
2026-07-11 18:23:56 +02:00
Agent Zero 9a269aa54f feat(T04): Rentman integration – equipment sync, API endpoints, frontend updates
- Update equipment model with Rentman-compatible fields
- Add/Update equipment router endpoints for sync operations
- Enhance rentman_service with full API client logic
- Improve sync_service for bidirectional equipment sync
- Update docker-compose with Rentman env vars
- Update frontend useApi composable and nuxt config
- Update mietkatalog pages with Rentman-integrated data display
- Add images/ to .gitignore (binary assets, 36MB)
2026-07-11 18:17:55 +02:00
Agent Zero 30db3491c0 fix: use model_dump() for Redis cache serialization - prevents 500 on cached equipment API calls 2026-07-11 18:17:55 +02:00
Leopoldadmin 4c63166384 fix: use naive UTC datetimes for TIMESTAMP WITHOUT TIME ZONE columns 2026-07-11 00:40:06 +00:00
Leopoldadmin 63316f1e92 Merge pull request 'docs: design-rules.md – binding design guidelines for AI agents' (#4) from feature/T04-rentman-integration into main 2026-07-10 21:09:15 +00:00
Leopoldadmin e0e170fe6a Merge pull request 'fix: 1:1 prototype port + deployment fixes' (#3) from feature/T04-rentman-integration into main 2026-07-10 20:50:02 +00:00
Leopoldadmin 0f569f2c6e Merge pull request 'feat: Complete HMS Licht & Ton Homepage (T01-T08)' (#2) from feature/T04-rentman-integration into main 2026-07-10 07:50:31 +00:00
132 changed files with 3648 additions and 490 deletions
+1
View File
@@ -9,3 +9,4 @@ htmlcov/
node_modules/
.nuxt/
dist/
images/
+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 .
+3 -4
View File
@@ -2,7 +2,7 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
import bcrypt
from fastapi import Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -12,17 +12,16 @@ from app.models.admin_user import AdminUser
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
+12 -34
View File
@@ -11,6 +11,7 @@ from app.cache import cache
from app.routers import equipment, health, admin, rental_requests, contact
from app.services.sync_service import SyncService
from app.services.email_service import EmailService
from app.mcp_server import get_mcp_app, mcp_session_manager
logger = logging.getLogger(__name__)
settings = get_settings()
@@ -38,57 +39,34 @@ async def run_email_retry() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: init DB, start scheduler, connect cache."""
# Create tables (for dev/testing; production uses Alembic)
await init_db()
# Connect Redis cache
await cache.connect()
# Start APScheduler
scheduler.add_job(
run_equipment_sync,
trigger="cron",
hour="*/6",
id="equipment_sync",
replace_existing=True,
)
scheduler.add_job(
run_email_retry,
trigger="cron",
minute="*/15",
id="email_retry",
replace_existing=True,
)
_mcp_cm = mcp_session_manager.run()
await _mcp_cm.__aenter__()
scheduler.add_job(run_equipment_sync, trigger="cron", hour="*/6", id="equipment_sync", replace_existing=True)
scheduler.add_job(run_email_retry, trigger="cron", minute="*/15", id="email_retry", replace_existing=True)
scheduler.start()
logger.info("APScheduler started with equipment_sync (every 6h) and email_retry (every 15min)")
logger.info("APScheduler started")
yield
# Shutdown
scheduler.shutdown(wait=False)
await _mcp_cm.__aexit__(None, None, None)
await cache.disconnect()
await engine.dispose()
app = FastAPI(
title="HMS Licht & Ton API",
version="1.0.0",
lifespan=lifespan,
)
app = FastAPI(title="HMS Licht & Ton API", version="1.0.0", lifespan=lifespan)
# CORS
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_methods=["GET", "POST"],
allow_headers=["*"],
allow_credentials=True,
)
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["GET", "POST"], allow_headers=["*"], allow_credentials=True)
# Routers
app.include_router(equipment.router)
app.include_router(health.router)
app.include_router(admin.router)
app.include_router(rental_requests.router)
app.include_router(contact.router)
app.mount("/mcp", get_mcp_app())
+558
View File
@@ -0,0 +1,558 @@
"""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
from mcp.server.transport_security import TransportSecuritySettings
mcp = FastMCP("hms-cms", streamable_http_path="/", stateless_http=True, transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False))
# ---------------------------------------------------------------------------#
# 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)
+8
View File
@@ -15,12 +15,20 @@ class EquipmentCache(Base):
description = Column(Text)
specifications = Column(JSON)
images = Column(JSON)
update_hash = Column(String(128))
rental_price = Column(DECIMAL(10, 2))
brand = Column(String(128))
available = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
@property
def image_url(self) -> str | None:
"""Return local image URL if images exist, else None."""
if self.images and len(self.images) > 0:
return f"/api/equipment/{self.id}/image"
return None
__table_args__ = (
Index("idx_equipment_category", "category"),
Index("idx_equipment_name", "name"),
+17 -4
View File
@@ -1,5 +1,7 @@
"""Equipment API router: list, detail, categories."""
"""Equipment API router: list, detail, categories, image."""
import os
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any
@@ -10,6 +12,8 @@ from app.cache import cache
router = APIRouter(prefix="/api/equipment", tags=["equipment"])
IMAGES_DIR = "/data/images/equipment"
@router.get("", response_model=PaginatedResponse)
async def list_equipment(
@@ -49,7 +53,7 @@ async def list_equipment(
items = result.scalars().all()
response = {
"items": [EquipmentItem.model_validate(item) for item in items],
"items": [EquipmentItem.model_validate(item).model_dump() for item in items],
"total": total,
"page": page,
"page_size": page_size,
@@ -73,6 +77,15 @@ async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
return categories
@router.get("/{equipment_id}/image")
async def get_equipment_image(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
"""Serve the locally stored image for an equipment item."""
image_path = os.path.join(IMAGES_DIR, f"{equipment_id}.jpg")
if os.path.exists(image_path):
return FileResponse(image_path, media_type="image/jpeg")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
@router.get("/{equipment_id}", response_model=EquipmentDetail)
async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
"""Return a single equipment detail by ID."""
@@ -85,6 +98,6 @@ async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
response = EquipmentDetail.model_validate(item)
await cache.set(cache_key, response.model_dump(), ttl=3600)
response = EquipmentDetail.model_validate(item).model_dump()
await cache.set(cache_key, response, ttl=3600)
return response
+59 -29
View File
@@ -50,6 +50,65 @@ class RentmanService:
offset += limit
return all_items
async def get_file_url(self, file_id: str | int) -> str | None:
"""Fetch the S3 URL for a file from Rentman.
GET /files/{file_id} → response contains 'url' field with S3 link.
"""
url = f"{self._base_url}/files/{file_id}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(url, headers=self._headers())
resp.raise_for_status()
data = resp.json()
file_data = data.get("data", data)
file_url = file_data.get("url")
if file_url:
return file_url
logger.warning("No url field in file response for file_id=%s", file_id)
return None
except Exception as exc:
logger.warning("Failed to fetch file URL for file_id=%s: %s", file_id, exc)
return None
async def transform_equipment(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Map a raw Rentman equipment object to equipment_cache schema.
Uses 'image' (singular) field which contains a relative path like '/files/3173'.
Fetches the actual S3 URL via GET /files/{file_id}.
"""
image_path = raw.get("image")
image_urls: list[str] = []
if image_path and isinstance(image_path, str):
# Extract file_id from path like /files/3173
match = re.search(r"/files/(\d+)", image_path)
if match:
file_id = match.group(1)
s3_url = await self.get_file_url(file_id)
if s3_url:
image_urls = [s3_url]
else:
logger.debug("Could not extract file_id from image path: %s", image_path)
group = raw.get("equipment_group") or {}
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
return {
"rentman_id": str(raw.get("id", "")),
"name": raw.get("name", ""),
"number": raw.get("number") or raw.get("code", ""),
"category": category,
"subcategory": raw.get("subcategory", ""),
"description": raw.get("description", ""),
"specifications": raw.get("specifications", {}),
"images": image_urls,
"rental_price": raw.get("rental_price"),
"brand": raw.get("brand", ""),
"available": raw.get("available", True),
"update_hash": raw.get("updateHash", ""),
}
async def create_project_request(self, payload: dict[str, Any]) -> dict[str, Any]:
"""POST /projectrequests to create a new project request in Rentman."""
url = f"{self._base_url}/projectrequests"
@@ -68,35 +127,6 @@ class RentmanService:
resp.raise_for_status()
return resp.json()
@staticmethod
def transform_equipment(raw: dict[str, Any]) -> dict[str, Any]:
"""Map a raw Rentman equipment object to equipment_cache schema."""
images = raw.get("images") or raw.get("files") or []
if isinstance(images, list):
image_urls = [
img.get("url", img.get("filename", "")) if isinstance(img, dict) else str(img)
for img in images
]
else:
image_urls = []
group = raw.get("equipment_group") or {}
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
return {
"rentman_id": str(raw.get("id", "")),
"name": raw.get("name", ""),
"number": raw.get("number") or raw.get("code", ""),
"category": category,
"subcategory": raw.get("subcategory", ""),
"description": raw.get("description", ""),
"specifications": raw.get("specifications", {}),
"images": image_urls,
"rental_price": raw.get("rental_price"),
"brand": raw.get("brand", ""),
"available": raw.get("available", True),
}
@staticmethod
def build_project_request_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Map frontend rental request data to Rentman POST /projectrequests payload."""
+90 -9
View File
@@ -1,6 +1,8 @@
"""Equipment sync service: import from Rentman, upsert into DB, invalidate cache."""
import logging
from datetime import datetime, timezone
import os
import httpx
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +13,8 @@ from app.cache import cache
logger = logging.getLogger(__name__)
IMAGES_DIR = "/data/images/equipment"
class SyncService:
"""Orchestrates equipment import from Rentman into the local database."""
@@ -20,19 +24,21 @@ class SyncService:
self.rentman = rentman or RentmanService()
async def run_sync(self) -> dict[str, Any]:
"""Execute a full equipment sync.
"""Execute an incremental equipment sync.
1. Create sync_log entry (status=running)
2. Paginate GET /equipment from Rentman
3. Upsert each item into equipment_cache
4. Invalidate Redis cache (equipment:*)
5. Update sync_log (status=completed or failed)
3. Compare updateHash with DB, only process changed items
4. Download images only for changed items
5. Mark missing items as unavailable
6. Invalidate Redis cache (equipment:*)
7. Update sync_log (status=completed or failed)
Returns dict with sync_id, items_processed, status.
"""
log_entry = SyncLog(
sync_type="equipment",
status="running",
started_at=datetime.now(timezone.utc),
started_at=datetime.utcnow(),
)
self.db.add(log_entry)
await self.db.commit()
@@ -44,16 +50,53 @@ class SyncService:
error_message: str | None = None
try:
# Ensure images directory exists
os.makedirs(IMAGES_DIR, exist_ok=True)
# Fetch all equipment from Rentman
all_equipment = await self.rentman.get_all_equipment(limit=100)
# Build set of rentman_ids from API for availability check
api_rentman_ids = set()
# Load all existing equipment from DB for hash comparison
result = await self.db.execute(select(EquipmentCache))
existing_items = {item.rentman_id: item for item in result.scalars().all()}
for raw_item in all_equipment:
rentman_id = str(raw_item.get("id", ""))
api_rentman_ids.add(rentman_id)
new_hash = raw_item.get("updateHash", "")
existing = existing_items.get(rentman_id)
# Skip if hash unchanged and item exists
if existing and existing.update_hash == new_hash and new_hash:
continue
try:
transformed = RentmanService.transform_equipment(raw_item)
transformed = await self.rentman.transform_equipment(raw_item)
await self._upsert_equipment(transformed)
# Download image if S3 URL available
if transformed.get("images"):
await self._download_image(
transformed["rentman_id"],
transformed["images"][0],
existing.id if existing else None,
)
items_processed += 1
except Exception as exc:
logger.warning("Failed to upsert equipment item: %s", exc)
logger.warning("Failed to upsert equipment item %s: %s", rentman_id, exc)
items_failed += 1
# Mark missing items as unavailable
for rentman_id, existing in existing_items.items():
if rentman_id not in api_rentman_ids and existing.available:
existing.available = False
logger.info("Marked equipment %s as unavailable (not in API)", rentman_id)
await self.db.commit()
# Invalidate Redis cache
@@ -70,7 +113,7 @@ class SyncService:
log_entry.items_processed = items_processed
log_entry.items_failed = items_failed
log_entry.error_message = error_message
log_entry.completed_at = datetime.now(timezone.utc)
log_entry.completed_at = datetime.utcnow()
await self.db.commit()
return {
@@ -95,6 +138,7 @@ class SyncService:
existing.description = data.get("description", "")
existing.specifications = data.get("specifications")
existing.images = data.get("images")
existing.update_hash = data.get("update_hash")
existing.rental_price = data.get("rental_price")
existing.brand = data.get("brand", "")
existing.available = data.get("available", True)
@@ -108,12 +152,49 @@ class SyncService:
description=data.get("description", ""),
specifications=data.get("specifications"),
images=data.get("images"),
update_hash=data.get("update_hash"),
rental_price=data.get("rental_price"),
brand=data.get("brand", ""),
available=data.get("available", True),
)
self.db.add(new_item)
async def _download_image(self, rentman_id: str, s3_url: str, db_id: int | None) -> None:
"""Download image from S3 URL and save locally.
File path: /data/images/equipment/{db_id or rentman_id}.jpg
"""
# Determine the file identifier - prefer DB id if available
# We need to flush to get the ID for new items
if db_id is None:
await self.db.flush()
result = await self.db.execute(
select(EquipmentCache).where(EquipmentCache.rentman_id == rentman_id)
)
item = result.scalar_one_or_none()
if item:
db_id = item.id
if db_id is None:
logger.warning("Could not determine DB id for image download: %s", rentman_id)
return
image_path = os.path.join(IMAGES_DIR, f"{db_id}.jpg")
# Skip if already exists
if os.path.exists(image_path):
return
try:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.get(s3_url)
resp.raise_for_status()
with open(image_path, "wb") as f:
f.write(resp.content)
logger.info("Downloaded image for equipment %s -> %s", db_id, image_path)
except Exception as exc:
logger.warning("Failed to download image for %s: %s", rentman_id, exc)
async def get_last_sync(self) -> dict[str, Any]:
"""Return the most recent sync_log entry summary."""
result = await self.db.execute(
+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
+26 -17
View File
@@ -1,27 +1,25 @@
services:
frontend:
build: ./frontend
ports:
- "3000:3000"
labels:
- traefik.http.services.https-0-wvus7va5u0f9dmg27ggca7rl-frontend.loadbalancer.server.port=3000
depends_on:
backend:
condition: service_healthy
environment:
- NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE:-http://backend:8000}
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
backend:
build: ./backend
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
@@ -40,16 +38,22 @@ services:
- 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:
- /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
postgres:
image: postgres:16-alpine
volumes:
@@ -58,34 +62,39 @@ services:
- POSTGRES_USER=hms
- POSTGRES_PASSWORD=hms
- POSTGRES_DB=hms
- POSTGRES_HOST_AUTH_METHOD=trust
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
redis:
image: redis:7-alpine
volumes:
- 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
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"]
@@ -9,7 +9,7 @@
:root {
/* Brand Accent - used sparingly */
--color-accent: #EC6925;
--color-accent: #FA5C01;
--color-accent-hover: #d4581a;
--color-accent-light: rgba(236, 105, 37, 0.08);
--color-accent-border: rgba(236, 105, 37, 0.25);
@@ -5,7 +5,7 @@
:root {
/* Brand Accent - used sparingly */
--color-accent: #EC6925;
--color-accent: #FA5C01;
--color-accent-hover: #d4581a;
--color-accent-light: rgba(236, 105, 37, 0.08);
--color-accent-border: rgba(236, 105, 37, 0.25);

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

@@ -1,7 +1,7 @@
<template>
<svg class="hms-logo-svg" viewBox="0 0 200 200" :style="`height:${size}px;width:auto`" aria-label="HMS Licht & Ton Logo" role="img">
<path fill="currentColor" 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="#EC6925" stroke-width="15.1525" stroke-miterlimit="10" width="155.612" height="168.874" />
<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>
</template>
@@ -2,16 +2,14 @@ import type { $Fetch } from "nitropack";
/**
* Base API client composable.
* Wraps $fetch with the configured API base URL from runtime config.
* Provides typed GET, POST, PUT, DELETE helpers.
* Uses server-side apiBase for SSR requests, public apiBase for client-side.
*/
export function useApi() {
const config = useRuntimeConfig();
const apiBase = config.public.apiBase;
// On server: config.apiBase (http://backend:8000)
// On client: config.public.apiBase (/api)
const apiBase = import.meta.server ? config.apiBase : config.public.apiBase;
/**
* Typed $fetch wrapper bound to API base URL.
*/
const client: $Fetch = $fetch.create({
baseURL: apiBase,
headers: {
@@ -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: {
@@ -51,12 +52,15 @@ export default defineNuxtConfig({
},
},
runtimeConfig: {
apiBase: process.env.API_BASE_URL || "http://localhost:8000",
// Server-only: used by SSR to call backend in Docker network
apiBase: process.env.API_BASE_URL || "http://backend:8000",
public: {
apiBase: process.env.API_BASE_URL || "http://localhost:8000",
// Client-side: relative URL goes through Nuxt server proxy
apiBase: "/api",
},
},
routeRules: {
"/": { ssr: false },
"/mietkatalog": { ssr: true },
"/mietkatalog/**": { ssr: true },
"/warenkorb": { ssr: false },
@@ -73,5 +77,11 @@ 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>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div><h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Mietkatalog</h1><p style="color: var(--secondary)">Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage zusammen.</p></div>
<button @click="navigate('/warenkorb')" class="hms-btn hms-btn-secondary text-sm self-start sm:self-auto">🛒 Warenkorb ansehen</button>
</div>
<div class="hms-card p-4 mb-6">
<div class="flex flex-col lg:flex-row gap-4">
<div class="relative flex-1"><svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5" style="color: var(--secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg><input v-model="searchQuery" type="search" class="hms-input pl-10" placeholder="Gerät, Marke oder Artikelnummer suchen..." aria-label="Equipment suchen" @input="onSearchInput" /></div>
<select v-model="sortBy" class="hms-input lg:w-48" aria-label="Sortieren nach"><option value="name_asc">Sortieren: Name (A-Z)</option><option value="name_desc">Sortieren: Name (Z-A)</option></select>
</div>
<div v-if="categories.length > 1" class="flex flex-wrap gap-2 mt-4" role="tablist" aria-label="Kategorie-Filter"><button v-for="cat in categories" :key="cat" @click="activeCategory = cat; reload()" :class="['hms-chip', activeCategory === cat ? 'active' : '']" role="tab" :aria-selected="activeCategory === cat">{{ cat === 'alle' ? 'Alle Kategorien' : cat }}</button></div>
</div>
<div v-if="!pending && !error" class="text-sm mb-4" style="color: var(--secondary)" aria-live="polite">{{ total }} {{ total === 1 ? 'Gerät' : 'Geräte' }} gefunden</div>
<LoadingSkeleton v-if="pending" :count="6" />
<ErrorState v-else-if="error" title="Katalog nicht erreichbar" message="Der Equipment-Katalog ist aktuell nicht verfügbar." @retry="reload" />
<EmptyState v-else-if="equipment.length === 0" icon="🔍" title="Keine Geräte gefunden" message="Für Ihre Suchanfrage wurden keine Geräte gefunden." action-label="Filter zurücksetzen" @action="searchQuery=''; activeCategory='alle'; reload()" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"><EquipmentCard v-for="item in equipment" :key="item.id" :item="mapItem(item)" @click="navigateToDetail" @add-to-cart="addToCart" /></div>
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 mt-8">
<button :disabled="currentPage <= 1" @click="currentPage--; reload()" class="hms-btn hms-btn-secondary px-4 py-2 disabled:opacity-40" aria-label="Vorherige Seite"></button>
<span class="text-sm" style="color: var(--secondary)">Seite {{ currentPage }} von {{ totalPages }}</span>
<button :disabled="currentPage >= totalPages" @click="currentPage++; reload()" class="hms-btn hms-btn-secondary px-4 py-2 disabled:opacity-40" aria-label="Nächste Seite"></button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useEquipment } from '~/composables/useEquipment'
import { useCart } from '~/composables/useCart'
const { list, categories: fetchCategories } = useEquipment()
const { addItemByFields } = useCart()
const searchQuery = ref('')
const activeCategory = ref('alle')
const sortBy = ref('name_asc')
const currentPage = ref(1)
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const categories = ref<string[]>(['alle'])
const { data, pending, error, refresh } = await useAsyncData(
'equipment-list',
() => list({
search: searchQuery.value.trim() || undefined,
category: activeCategory.value !== 'alle' ? activeCategory.value : undefined,
sort: sortBy.value as any,
page: currentPage.value,
page_size: 24
}),
{ default: () => ({ items: [], total: 0, page: 1, page_size: 24, total_pages: 0 }) }
)
const equipment = computed(() => data.value?.items || [])
const total = computed(() => data.value?.total || 0)
const totalPages = computed(() => data.value?.total_pages || 0)
function reload() {
refresh()
}
function onSearchInput() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
currentPage.value = 1
reload()
}, 300)
}
useAsyncData('equipment-categories', async () => {
try {
const cats = await fetchCategories()
if (cats && cats.length > 0) {
categories.value = ['alle', ...cats.filter((c: string) => c && c.trim() !== '')]
}
} catch {}
})
function mapItem(item: any) {
return {
...item,
code: item.number || item.rentman_id,
image: item.image_url || ''
}
}
function navigate(route: string) {
navigateTo(route)
if (import.meta.client) window.scrollTo(0, 0)
}
function navigateToDetail(item: any) {
navigateTo('/mietkatalog/' + item.id)
if (import.meta.client) window.scrollTo(0, 0)
}
function addToCart(item: any) {
addItemByFields({
equipment_id: item.id,
name: item.name,
rental_price: null,
image_url: item.image_url || null
})
}
</script>
@@ -0,0 +1,64 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<nav class="text-sm mb-6" aria-label="Breadcrumb"><NuxtLink to="/mietkatalog" style="color: var(--secondary)"> Zurück zum Katalog</NuxtLink></nav>
<div v-if="pending" class="grid lg:grid-cols-2 gap-8" aria-live="polite" aria-busy="true"><div class="hms-skeleton aspect-square rounded-lg"></div><div class="space-y-4"><div class="hms-skeleton h-8 w-3/4"></div><div class="hms-skeleton h-4 w-1/4"></div><div class="hms-skeleton h-24 w-full"></div><div class="hms-skeleton h-32 w-full"></div><div class="hms-skeleton h-12 w-full" style="border-radius:var(--radius-md)"></div></div></div>
<ErrorState v-else-if="error" title="Gerät nicht gefunden" message="Das angeforderte Gerät konnte nicht gefunden werden." @retry="navigate('/mietkatalog')" />
<div v-else-if="item">
<div class="grid lg:grid-cols-2 gap-8 lg:gap-12">
<div class="aspect-square rounded-lg flex items-center justify-center relative overflow-hidden border" :style="{ background: 'var(--surface)', borderColor: 'var(--border)' }">
<img v-if="item.image_url" :src="item.image_url" :alt="item.name" class="absolute inset-0 w-full h-full object-cover" />
<div v-else class="text-center"><div class="text-8xl mb-2" style="color: var(--secondary)">📦</div><div class="text-sm" style="color: var(--secondary)">Kein Bild verfügbar</div></div>
<span v-if="item.category" class="hms-badge hms-badge-primary absolute top-4 left-4 text-sm">{{ item.category }}</span>
</div>
<div>
<div class="text-sm mb-1" style="color: var(--secondary)">Artikelnummer: {{ item.number || item.rentman_id }}</div>
<h1 class="text-2xl sm:text-3xl font-bold mb-2" style="color: var(--text)">{{ item.name }}</h1>
<div v-if="item.brand" class="text-sm mb-4" style="color: var(--text-muted)">Marke: <span class="font-medium" style="color: var(--text)">{{ item.brand }}</span></div>
<p v-if="item.description" class="leading-relaxed mb-6" style="color: var(--text-muted)">{{ item.description }}</p>
<div v-if="item.specifications" class="hms-card p-4 mb-6"><h2 class="text-sm font-semibold mb-3" style="color: var(--text)">Technische Daten</h2><dl class="divide-y" :style="{ borderColor: 'var(--border)' }"><div v-for="(value, key) in item.specifications" :key="key" class="flex justify-between py-2 text-sm" :style="{ borderColor: 'var(--border)' }"><dt class="capitalize" style="color: var(--secondary)">{{ key.replace(/_/g, ' ') }}</dt><dd class="font-medium text-right" style="color: var(--text)">{{ value }}</dd></div></dl></div>
<div class="hms-card p-6">
<h2 class="text-sm font-semibold mb-4" style="color: var(--text)">Mietanfrage</h2>
<div class="grid grid-cols-2 gap-3 mb-4"><div><label for="rental-start" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Mietbeginn</label><input id="rental-start" v-model="rentalStart" type="date" class="hms-input text-sm" /></div><div><label for="rental-end" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Mietende</label><input id="rental-end" v-model="rentalEnd" type="date" class="hms-input text-sm" /></div></div>
<div class="mb-4"><label for="quantity" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Anzahl</label><div class="flex items-center gap-3"><button @click="quantity = Math.max(1, quantity - 1)" class="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl verringern"></button><input id="quantity" v-model.number="quantity" type="number" min="1" class="hms-input text-center w-20" aria-label="Anzahl" /><button @click="quantity = quantity + 1" class="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl erhöhen">+</button></div></div>
<button @click="addToCart" class="hms-btn hms-btn-primary w-full py-3 text-base"><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="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>Zur Mietanfrage hinzufügen</button>
<p class="text-xs mt-3 text-center" style="color: var(--secondary)">Preise auf Anfrage unverbindliche Mietanfrage</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useEquipment } from '~/composables/useEquipment'
import { useCart } from '~/composables/useCart'
const route = useRoute()
const { detail } = useEquipment()
const { addItemByFields } = useCart()
const quantity = ref(1)
const rentalStart = ref('')
const rentalEnd = ref('')
const { data: item, pending, error } = await useAsyncData(
'equipment-detail',
() => detail(route.params.id as string)
)
function navigate(route: string) {
navigateTo(route)
if (import.meta.client) window.scrollTo(0, 0)
}
function addToCart() {
addItemByFields({
equipment_id: item.value!.id,
name: item.value!.name,
rental_price: null,
image_url: item.value!.image_url || null
})
navigate('/warenkorb')
}
</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>
+35
View File
@@ -0,0 +1,35 @@
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 /mcp {
proxy_pass http://backend:8000/mcp;
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;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
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>
-81
View File
@@ -1,81 +0,0 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div><h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Mietkatalog</h1><p style="color: var(--secondary)">Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage zusammen.</p></div>
<button @click="navigate('/warenkorb')" class="hms-btn hms-btn-secondary text-sm self-start sm:self-auto">🛒 Warenkorb ansehen</button>
</div>
<div class="hms-card p-4 mb-6">
<div class="flex flex-col lg:flex-row gap-4">
<div class="relative flex-1"><svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5" style="color: var(--secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg><input v-model="searchQuery" type="search" class="hms-input pl-10" placeholder="Gerät, Marke oder Artikelnummer suchen..." aria-label="Equipment suchen" /></div>
<select v-model="sortBy" class="hms-input lg:w-48" aria-label="Sortieren nach"><option value="name">Sortieren: Name (A-Z)</option><option value="brand">Sortieren: Marke (A-Z)</option><option value="code">Sortieren: Artikelnummer</option></select>
</div>
<div class="flex flex-wrap gap-2 mt-4" role="tablist" aria-label="Kategorie-Filter"><button v-for="cat in categories" :key="cat" @click="activeCategory = cat" :class="['hms-chip', activeCategory === cat ? 'active' : '']" role="tab" :aria-selected="activeCategory === cat">{{ cat === 'alle' ? 'Alle Kategorien' : cat }}</button></div>
</div>
<div v-if="!loading && !error" class="text-sm mb-4" style="color: var(--secondary)" aria-live="polite">{{ filteredEquipment.length }} {{ filteredEquipment.length === 1 ? 'Gerät' : 'Geräte' }} gefunden</div>
<LoadingSkeleton v-if="loading" :count="6" />
<ErrorState v-else-if="error" title="Katalog nicht erreichbar" message="Der Equipment-Katalog ist aktuell nicht verfügbar." @retry="retry" />
<EmptyState v-else-if="filteredEquipment.length === 0" icon="🔍" title="Keine Geräte gefunden" message="Für Ihre Suchanfrage wurden keine Geräte gefunden." action-label="Filter zurücksetzen" @action="searchQuery=''; activeCategory='alle'" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"><EquipmentCard v-for="item in filteredEquipment" :key="item.id" :item="item" @click="navigateToDetail" @add-to-cart="addToCart" /></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useCart } from '~/composables/useCart'
const { addItemByFields } = useCart()
const loading = ref(true)
const error = ref(false)
const searchQuery = ref('')
const activeCategory = ref('alle')
const sortBy = ref('name')
const categories = ['alle', 'Tontechnik', 'Lichttechnik', 'Rigging', 'Video', 'Strom', 'Zubehör']
const allEquipment = [
{ id: 1, code: 'LT-001', name: 'L-Acoustics K2', category: 'Tontechnik', description: 'High-Performance Line-Array Element mit variabler Krümmung für große Open-Air-Produktionen und Hallenbeschallung', brand: 'L-Acoustics', specs: { weight: '56 kg', power: '1440 W', freq_range: '35 Hz - 20 kHz' }, image: '' },
{ id: 2, code: 'LT-002', name: 'd&b audiotechnik KSL8', category: 'Tontechnik', description: 'Bi-amplified Column Array Loudspeaker, 80° horizontal, für mittlere bis große Events', brand: 'd&b audiotechnik', specs: { weight: '32 kg', power: '1200 W', freq_range: '55 Hz - 18 kHz' }, image: '' },
{ id: 3, code: 'LT-003', name: 'Shure SM58', category: 'Tontechnik', description: 'Dynamisches Gesangsmikrofon, Kardioid-Richtcharakteristik, Industrie-Standard seit Jahrzehnten', brand: 'Shure', specs: { weight: '330 g', type: 'dynamisch', polar: 'Kardioid' }, image: '' },
{ id: 4, code: 'LT-004', name: 'Allen & Heath dLive S5000', category: 'Tontechnik', description: 'Digitales Mischpult mit 96 Input Fadern, 64 Mix Buses, Dante-kompatibel für große Live-Produktionen', brand: 'Allen & Heath', specs: { channels: '96', buses: '64', fx: '16 Effekt-Engines' }, image: '' },
{ id: 5, code: 'LL-001', name: 'Robe Pointe', category: 'Lichttechnik', description: '230W Moving Beam mit 5°-20° Zoom, 16-bit Dimming, vielseitig für Beam- und Wash-Anwendungen', brand: 'Robe', specs: { power: '230 W', source: 'MSD 230', zoom: '5°-20°' }, image: '' },
{ id: 6, code: 'LL-002', name: 'Mac Aura PXL', category: 'Lichttechnik', description: 'RGBW Wash-Light mit 18 pixel-mappable Zellen, Zoom 4°-60°, Art-Net steuerbar', brand: 'Martin', specs: { power: '700 W', cells: '18', zoom: '4°-60°' }, image: '' },
{ id: 7, code: 'LL-003', name: 'Showtec Phantom 140', category: 'Lichttechnik', description: '140W LED Spot Moving Head, 9 rotierende + 9 feste Gobos, 8-fach Prisma', brand: 'Showtec', specs: { power: '140 W', gobos: '9 rotierend + 9 fest', zoom: '12°-18°' }, image: '' },
{ id: 8, code: 'LL-004', name: 'Astera Titan Tube', category: 'Lichttechnik', description: 'Pixel Tube 1m, batteriebetrieben mit 20 Std. Laufzeit, DMX/CRMX-Steuerung, RGBW', brand: 'Astera', specs: { length: '1015 mm', battery: '20 Std', cells: '16 Pixel' }, image: '' },
{ id: 9, code: 'RG-001', name: 'Tomcat Truss 290', category: 'Rigging', description: 'Aluminium Traverse 4-Punkt, 290mm Profil, 2m Länge, belastbar bis 450kg freitragend', brand: 'Tomcat', specs: { profile: '290x290 mm', length: '2 m', load: '450 kg' }, image: '' },
{ id: 10, code: 'RG-002', name: 'Chain Motor 1T', category: 'Rigging', description: 'Bühnen-Motor 1000kg, D8+ Plus geprüft, inklusive Steuerkabel und Lasthaken', brand: 'Verlinde', specs: { capacity: '1000 kg', speed: '4-8 m/min', control: 'Direct Control' }, image: '' },
{ id: 11, code: 'VD-001', name: 'LED Video Wall P3.9', category: 'Video', description: 'Indoor LED Panel P3.9, 500x500mm, 3840Hz Refresh Rate, 1500 nits für Bühnenhintergründe', brand: 'Absen', specs: { pixel_pitch: '3.9 mm', size: '500x500 mm', refresh: '3840 Hz' }, image: '' },
{ id: 12, code: 'VD-002', name: 'Blackmagic ATEM Mini Extreme', category: 'Video', description: '8-Input HDMI Switcher mit ISO Recording, Multiview und Direct Streaming', brand: 'Blackmagic', specs: { inputs: '8x HDMI', outputs: '2x HDMI', recording: 'ISO Recording' }, image: '' },
{ id: 13, code: 'ST-001', name: 'WAGO 24V 40A Netzteilkasten', category: 'Strom', description: '24V DC 40A Netzteil in Flightcase, 4x Schuko Ausgang, für LED- und Steuerungstechnik', brand: 'WAGO', specs: { output: '24V DC 40A', inputs: '4x Schuko', protection: 'IP54' }, image: '' },
{ id: 14, code: 'ST-002', name: 'Duraplex 32A Stromverteiler', category: 'Strom', description: '32A 5-polig auf 3x 16A + 3x Schuko, mit FI-Schutzschalter, professionelle Bühnenstromversorgung', brand: 'Duraplex', specs: { input: '32A 5-pol CEE', outputs: '3x 16A + 3x Schuko', protection: 'FI/A' }, image: '' },
{ id: 15, code: 'ZB-001', name: 'Flightcase 19" 12HE', category: 'Zubehör', description: '19" Rack Flightcase 12HE, Front- und Rücktür, 4x 100mm Rollen, Birkenholz mit Alu-Kanten', brand: 'Thon', specs: { rack_units: '12 HE', depth: '600 mm', wheels: '4x 100mm' }, image: '' },
{ id: 16, code: 'ZB-002', name: 'XLR Kabel 20m', category: 'Zubehör', description: 'XLR3 male/female Mikrofonkabel, 20m, Neutrik Stecker, doppelt geschirmt', brand: 'Tasker', specs: { length: '20 m', connectors: 'XLR3 M/F', shielding: 'double' }, image: '' },
{ id: 17, code: 'ZB-003', name: 'DJ-Table Pro', category: 'Zubehör', description: 'Mobile DJ-Tisch mit Laptop-Ständer und integrierter LED-Beleuchtung, transportiert im Flightcase', brand: 'Stage Traps', specs: { width: '120 cm', height: '95 cm', features: 'LED, Laptop-Ständer' }, image: '' },
{ id: 18, code: 'LT-005', name: 'Sennheiser EW-DX 835', category: 'Tontechnik', description: 'Digitales Funkmikrofon-Set, Handheld + Empfänger, 100m Reichweite, 12 Std. Akkulaufzeit', brand: 'Sennheiser', specs: { type: 'Digital Wireless', range: '100 m', battery: '12 Std' }, image: '' }
]
const filteredEquipment = computed(() => {
let items = allEquipment
if (activeCategory.value !== 'alle') items = items.filter(i => i.category === activeCategory.value)
if (searchQuery.value.trim()) { const q = searchQuery.value.toLowerCase(); items = items.filter(i => i.name.toLowerCase().includes(q) || i.code.toLowerCase().includes(q) || i.brand.toLowerCase().includes(q)) }
if (sortBy.value === 'name') items = [...items].sort((a, b) => a.name.localeCompare(b.name))
else if (sortBy.value === 'brand') items = [...items].sort((a, b) => a.brand.localeCompare(b.brand))
else if (sortBy.value === 'code') items = [...items].sort((a, b) => a.code.localeCompare(b.code))
return items
})
onMounted(() => { setTimeout(() => { loading.value = false }, 1000) })
function navigate(route: string) {
navigateTo(route)
if (import.meta.client) window.scrollTo(0, 0)
}
function navigateToDetail(item: any) {
navigateTo('/mietkatalog/' + item.id)
if (import.meta.client) window.scrollTo(0, 0)
}
function retry() { loading.value = true; error.value = false; setTimeout(() => { loading.value = false }, 800) }
function addToCart(item: any) {
addItemByFields({
equipment_id: item.id,
name: item.name,
rental_price: null,
image_url: item.image || null
})
}
</script>
-103
View File
@@ -1,103 +0,0 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<nav class="text-sm mb-6" aria-label="Breadcrumb"><NuxtLink to="/mietkatalog" style="color: var(--secondary)"> Zurück zum Katalog</NuxtLink></nav>
<div v-if="loading" class="grid lg:grid-cols-2 gap-8" aria-live="polite" aria-busy="true"><div class="hms-skeleton aspect-square rounded-lg"></div><div class="space-y-4"><div class="hms-skeleton h-8 w-3/4"></div><div class="hms-skeleton h-4 w-1/4"></div><div class="hms-skeleton h-24 w-full"></div><div class="hms-skeleton h-32 w-full"></div><div class="hms-skeleton h-12 w-full" style="border-radius:var(--radius-md)"></div></div></div>
<ErrorState v-else-if="error" title="Gerät nicht gefunden" message="Das angeforderte Gerät konnte nicht gefunden werden." @retry="navigate('/mietkatalog')" />
<div v-else-if="item">
<div class="grid lg:grid-cols-2 gap-8 lg:gap-12">
<div class="aspect-square rounded-lg flex items-center justify-center relative overflow-hidden border" :style="{ background: 'var(--surface)', borderColor: 'var(--border)' }">
<img v-if="item.image" :src="item.image" :alt="item.name" class="absolute inset-0 w-full h-full object-cover" />
<div v-else class="text-center"><div class="text-8xl mb-2" style="color: var(--secondary)">📦</div><div class="text-sm" style="color: var(--secondary)">Kein Bild verfügbar</div></div>
<span class="hms-badge hms-badge-primary absolute top-4 left-4 text-sm">{{ item.category }}</span>
</div>
<div>
<div class="text-sm mb-1" style="color: var(--secondary)">Artikelnummer: {{ item.code }}</div>
<h1 class="text-2xl sm:text-3xl font-bold mb-2" style="color: var(--text)">{{ item.name }}</h1>
<div class="text-sm mb-4" style="color: var(--text-muted)">Marke: <span class="font-medium" style="color: var(--text)">{{ item.brand }}</span></div>
<p class="leading-relaxed mb-6" style="color: var(--text-muted)">{{ item.description }}</p>
<div class="hms-card p-4 mb-6"><h2 class="text-sm font-semibold mb-3" style="color: var(--text)">Technische Daten</h2><dl class="divide-y" :style="{ borderColor: 'var(--border)' }"><div v-for="(value, key) in item.specs" :key="key" class="flex justify-between py-2 text-sm" :style="{ borderColor: 'var(--border)' }"><dt class="capitalize" style="color: var(--secondary)">{{ key.replace(/_/g, ' ') }}</dt><dd class="font-medium text-right" style="color: var(--text)">{{ value }}</dd></div></dl></div>
<div class="hms-card p-6">
<h2 class="text-sm font-semibold mb-4" style="color: var(--text)">Mietanfrage</h2>
<div class="grid grid-cols-2 gap-3 mb-4"><div><label for="rental-start" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Mietbeginn</label><input id="rental-start" v-model="rentalStart" type="date" class="hms-input text-sm" /></div><div><label for="rental-end" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Mietende</label><input id="rental-end" v-model="rentalEnd" type="date" class="hms-input text-sm" /></div></div>
<div class="mb-4"><label for="quantity" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Anzahl</label><div class="flex items-center gap-3"><button @click="quantity = Math.max(1, quantity - 1)" class="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl verringern"></button><input id="quantity" v-model.number="quantity" type="number" min="1" class="hms-input text-center w-20" aria-label="Anzahl" /><button @click="quantity = quantity + 1" class="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl erhöhen">+</button></div></div>
<button @click="addToCart" class="hms-btn hms-btn-primary w-full py-3 text-base"><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="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>Zur Mietanfrage hinzufügen</button>
<p class="text-xs mt-3 text-center" style="color: var(--secondary)">Preise auf Anfrage unverbindliche Mietanfrage</p>
</div>
</div>
</div>
<section v-if="relatedItems.length" class="mt-16" aria-labelledby="related-title"><h2 id="related-title" class="text-xl font-semibold mb-6" style="color: var(--text)">Ähnliche Geräte</h2><div class="grid grid-cols-1 sm:grid-cols-3 gap-6"><EquipmentCard v-for="ri in relatedItems" :key="ri.id" :item="ri" @click="navigate('/mietkatalog/' + ri.id)" @add-to-cart="addToCartSimple" /></div></section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useCart } from '~/composables/useCart'
const route = useRoute()
const { addItemByFields } = useCart()
const loading = ref(true)
const error = ref(false)
const quantity = ref(1)
const rentalStart = ref('')
const rentalEnd = ref('')
const item = ref<any>(null)
const equipmentData = [
{ id: 1, code: 'LT-001', name: 'L-Acoustics K2', category: 'Tontechnik', description: 'Das L-Acoustics K2 ist das Flaggschiff der Line-Array-Serie und eignet sich für große Open-Air-Veranstaltungen und Hallenbeschallung.', brand: 'L-Acoustics', specs: { weight: '56 kg', power: '1440 W', freq_range: '35 Hz - 20 kHz', max_spl: '142 dB', coverage: '10°-110° variable' }, image: '', related: [2, 4, 3] },
{ id: 2, code: 'LT-002', name: 'd&b audiotechnik KSL8', category: 'Tontechnik', description: 'Bi-amplified Column Array Loudspeaker mit 80° horizontaler Abstrahlung.', brand: 'd&b audiotechnik', specs: { weight: '32 kg', power: '1200 W', freq_range: '55 Hz - 18 kHz', max_spl: '138 dB', coverage: '80° horizontal' }, image: '', related: [1, 4, 18] },
{ id: 3, code: 'LT-003', name: 'Shure SM58', category: 'Tontechnik', description: 'Das weltweit meistverkaufte dynamische Gesangsmikrofon.', brand: 'Shure', specs: { weight: '330 g', type: 'dynamisch', polar: 'Kardioid', freq_range: '50 Hz - 15 kHz', connector: 'XLR3' }, image: '', related: [4, 18, 1] },
{ id: 4, code: 'LT-004', name: 'Allen & Heath dLive S5000', category: 'Tontechnik', description: 'Professionelles digitales Mischpult mit 96 Input Fadern und 64 Mix Buses.', brand: 'Allen & Heath', specs: { channels: '96', buses: '64', fx: '16 Effekt-Engines', screens: '2x 15" Touch', i_o: '128x128 Dante' }, image: '', related: [1, 2, 3] },
{ id: 5, code: 'LL-001', name: 'Robe Pointe', category: 'Lichttechnik', description: '230W Moving Beam mit 5°-20° Zoom und 16-bit Dimming.', brand: 'Robe', specs: { power: '230 W', source: 'MSD 230', zoom: '5°-20°', dimming: '16-bit', pan: '540°', tilt: '270°' }, image: '', related: [6, 7, 8] },
{ id: 6, code: 'LL-002', name: 'Mac Aura PXL', category: 'Lichttechnik', description: 'RGBW Wash-Light mit 18 einzelnen pixel-mappable Zellen.', brand: 'Martin', specs: { power: '700 W', cells: '18', zoom: '4°-60°', control: 'DMX 512, Art-Net', color: 'RGBW' }, image: '', related: [5, 7, 8] },
{ id: 7, code: 'LL-003', name: 'Showtec Phantom 140', category: 'Lichttechnik', description: '140W LED Spot Moving Head mit 9 rotierenden und 9 festen Gobos.', brand: 'Showtec', specs: { power: '140 W', gobos: '9 rotierend + 9 fest', zoom: '12°-18°', color: '7+1 Farbrad', prism: '8-fach rotierend' }, image: '', related: [5, 6, 8] },
{ id: 8, code: 'LL-004', name: 'Astera Titan Tube', category: 'Lichttechnik', description: 'Pixel Tube 1m Länge, batteriebetrieben mit 20 Std. Laufzeit.', brand: 'Astera', specs: { length: '1015 mm', battery: '20 Std', cells: '16 Pixel', control: 'DMX, CRMX, App', color: 'RGBW' }, image: '', related: [5, 6, 7] },
{ id: 9, code: 'RG-001', name: 'Tomcat Truss 290', category: 'Rigging', description: 'Aluminium Traverse 4-Punkt, 290mm Profil, 2m Länge.', brand: 'Tomcat', specs: { profile: '290x290 mm', length: '2 m', load: '450 kg', material: 'Aluminium EN AW-6082' }, image: '', related: [10, 14, 15] },
{ id: 10, code: 'RG-002', name: 'Chain Motor 1T', category: 'Rigging', description: 'Bühnen-Motor 1000kg Tragkraft, D8+ Plus geprüft.', brand: 'Verlinde', specs: { capacity: '1000 kg', speed: '4-8 m/min', control: 'Direct Control', certification: 'D8+ Plus' }, image: '', related: [9, 14, 13] },
{ id: 11, code: 'VD-001', name: 'LED Video Wall P3.9', category: 'Video', description: 'Indoor LED Panel P3.9, 500x500mm, 3840Hz Refresh Rate.', brand: 'Absen', specs: { pixel_pitch: '3.9 mm', size: '500x500 mm', refresh: '3840 Hz', brightness: '1500 nits', ip: 'IP30 Indoor' }, image: '', related: [12, 5, 6] },
{ id: 12, code: 'VD-002', name: 'Blackmagic ATEM Mini Extreme', category: 'Video', description: '8-Input HDMI Switcher mit ISO Recording und Multiview.', brand: 'Blackmagic', specs: { inputs: '8x HDMI', outputs: '2x HDMI', recording: 'ISO Recording', streaming: 'Direct Streaming', audio: '2x 3.5mm' }, image: '', related: [11, 5, 7] },
{ id: 13, code: 'ST-001', name: 'WAGO 24V 40A Netzteilkasten', category: 'Strom', description: '24V DC 40A Netzteil in Flightcase mit 4x Schuko Ausgang.', brand: 'WAGO', specs: { output: '24V DC 40A', inputs: '4x Schuko', protection: 'IP54', cooling: 'Forced Air' }, image: '', related: [14, 10, 9] },
{ id: 14, code: 'ST-002', name: 'Duraplex 32A Stromverteiler', category: 'Strom', description: '32A 5-polig auf 3x 16A + 3x Schuko mit FI-Schutzschalter.', brand: 'Duraplex', specs: { input: '32A 5-pol CEE', outputs: '3x 16A + 3x Schuko', protection: 'FI/A', cable: 'H07BQ-F 5G6' }, image: '', related: [13, 10, 9] },
{ id: 15, code: 'ZB-001', name: 'Flightcase 19" 12HE', category: 'Zubehör', description: '19" Rack Flightcase 12HE mit Front- und Rücktür.', brand: 'Thon', specs: { rack_units: '12 HE', depth: '600 mm', wheels: '4x 100mm', material: 'Birkenholz + Alu-Kanten' }, image: '', related: [16, 4, 15] },
{ id: 16, code: 'ZB-002', name: 'XLR Kabel 20m', category: 'Zubehör', description: 'XLR3 male/female Mikrofonkabel, 20m, Neutrik Stecker.', brand: 'Tasker', specs: { length: '20 m', connectors: 'XLR3 M/F', shielding: 'double', cable: 'Tasker C118' }, image: '', related: [3, 18, 4] },
{ id: 17, code: 'ZB-003', name: 'DJ-Table Pro', category: 'Zubehör', description: 'Mobile DJ-Tisch mit Laptop-Ständer und LED-Beleuchtung.', brand: 'Stage Traps', specs: { width: '120 cm', height: '95 cm', features: 'LED, Laptop-Ständer', weight: '22 kg' }, image: '', related: [12, 5, 15] },
{ id: 18, code: 'LT-005', name: 'Sennheiser EW-DX 835', category: 'Tontechnik', description: 'Digitales Funkmikrofon-Set mit Handheld und Empfänger.', brand: 'Sennheiser', specs: { type: 'Digital Wireless', range: '100 m', battery: '12 Std', freq_range: '563-608 MHz', channels: 'gleichzeitig 95' }, image: '', related: [3, 4, 16] }
]
const relatedItems = computed(() => {
if (!item.value || !item.value.related) return []
return item.value.related.map((id: number) => equipmentData.find(e => e.id === id)).filter(Boolean)
})
onMounted(() => {
setTimeout(() => {
const found = equipmentData.find(e => e.id === Number(route.params.id))
if (found) { item.value = found } else { error.value = true }
loading.value = false
}, 800)
})
function navigate(route: string) {
navigateTo(route)
if (import.meta.client) window.scrollTo(0, 0)
}
function addToCart() {
addItemByFields({
equipment_id: item.value.id,
name: item.value.name,
rental_price: null,
image_url: item.value.image || null
})
navigate('/warenkorb')
}
function addToCartSimple(e: any) {
addItemByFields({
equipment_id: e.id,
name: e.name,
rental_price: null,
image_url: e.image || null
})
}
</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);
}

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