Compare commits
19 Commits
8dae2c0301
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 489419a7d4 | |||
| f815e64499 | |||
| e5606c64c5 | |||
| a08bdaa3b1 | |||
| 36a1ad0b84 | |||
| 80cb971308 | |||
| 6483453437 | |||
| 1221350ed6 | |||
| 620f7b3153 | |||
| cd465e0564 | |||
| 00c23bc066 | |||
| 5f67cc9140 | |||
| a6375567b0 | |||
| 9a269aa54f | |||
| 30db3491c0 | |||
| 4c63166384 | |||
| 63316f1e92 | |||
| e0e170fe6a | |||
| 0f569f2c6e |
@@ -9,3 +9,4 @@ htmlcov/
|
||||
node_modules/
|
||||
.nuxt/
|
||||
dist/
|
||||
images/
|
||||
|
||||
@@ -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 .
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
playwright-report
|
||||
test-results
|
||||
coverage
|
||||
@@ -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/**",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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: [],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
@@ -1,12 +1,3 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
playwright-report
|
||||
test-results
|
||||
coverage
|
||||
|
||||
@@ -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;"]
|
||||
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 608 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 541 KiB |
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||