Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 489419a7d4 | |||
| f815e64499 | |||
| e5606c64c5 | |||
| a08bdaa3b1 | |||
| 36a1ad0b84 | |||
| 80cb971308 | |||
| 6483453437 | |||
| 1221350ed6 | |||
| 620f7b3153 | |||
| cd465e0564 | |||
| 00c23bc066 | |||
| 5f67cc9140 | |||
| a6375567b0 | |||
| 4c63166384 | |||
| 63316f1e92 | |||
| e0e170fe6a | |||
| 0f569f2c6e |
@@ -2,9 +2,9 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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 \
|
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/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
import bcrypt
|
||||||
from fastapi import Depends, HTTPException, status, Request
|
from fastapi import Depends, HTTPException, status, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -12,17 +12,16 @@ from app.models.admin_user import AdminUser
|
|||||||
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
"""Verify a plain password against a hash."""
|
"""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:
|
def get_password_hash(password: str) -> str:
|
||||||
"""Hash a password using bcrypt."""
|
"""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:
|
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.routers import equipment, health, admin, rental_requests, contact
|
||||||
from app.services.sync_service import SyncService
|
from app.services.sync_service import SyncService
|
||||||
from app.services.email_service import EmailService
|
from app.services.email_service import EmailService
|
||||||
|
from app.mcp_server import get_mcp_app, mcp_session_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -38,57 +39,34 @@ async def run_email_retry() -> None:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan: init DB, start scheduler, connect cache."""
|
"""Application lifespan: init DB, start scheduler, connect cache."""
|
||||||
# Create tables (for dev/testing; production uses Alembic)
|
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
# Connect Redis cache
|
|
||||||
await cache.connect()
|
await cache.connect()
|
||||||
|
|
||||||
# Start APScheduler
|
_mcp_cm = mcp_session_manager.run()
|
||||||
scheduler.add_job(
|
await _mcp_cm.__aenter__()
|
||||||
run_equipment_sync,
|
|
||||||
trigger="cron",
|
scheduler.add_job(run_equipment_sync, trigger="cron", hour="*/6", id="equipment_sync", replace_existing=True)
|
||||||
hour="*/6",
|
scheduler.add_job(run_email_retry, trigger="cron", minute="*/15", id="email_retry", replace_existing=True)
|
||||||
id="equipment_sync",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
run_email_retry,
|
|
||||||
trigger="cron",
|
|
||||||
minute="*/15",
|
|
||||||
id="email_retry",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("APScheduler started with equipment_sync (every 6h) and email_retry (every 15min)")
|
logger.info("APScheduler started")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
await _mcp_cm.__aexit__(None, None, None)
|
||||||
await cache.disconnect()
|
await cache.disconnect()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(title="HMS Licht & Ton API", version="1.0.0", lifespan=lifespan)
|
||||||
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()]
|
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||||
app.add_middleware(
|
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["GET", "POST"], allow_headers=["*"], allow_credentials=True)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=origins,
|
|
||||||
allow_methods=["GET", "POST"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Routers
|
|
||||||
app.include_router(equipment.router)
|
app.include_router(equipment.router)
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(rental_requests.router)
|
app.include_router(rental_requests.router)
|
||||||
app.include_router(contact.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)
|
||||||
@@ -16,3 +16,4 @@ pytest>=8.0.0
|
|||||||
pytest-asyncio>=0.23.0
|
pytest-asyncio>=0.23.0
|
||||||
pytest-cov>=5.0.0
|
pytest-cov>=5.0.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
mcp>=1.0.0
|
||||||
|
|||||||
@@ -1,37 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
ports:
|
labels:
|
||||||
- "3000:3000"
|
- traefik.http.services.https-0-wvus7va5u0f9dmg27ggca7rl-frontend.loadbalancer.server.port=3000
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
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
|
restart: unless-stopped
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
networks:
|
networks:
|
||||||
- hms-network
|
- hms-network
|
||||||
- coolify
|
- coolify
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -40,68 +26,75 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://hms:hms@postgres:5432/hms}
|
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://hms:hms@postgres:5432/hms}
|
||||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||||
- RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN}
|
- RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
- SMTP_USER=${SMTP_USER}
|
- SMTP_USER=${SMTP_USER}
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
- SMTP_FROM=${SMTP_FROM:-info@hms-licht-ton.de}
|
- SMTP_FROM=${SMTP_FROM:-info@hms-licht-ton.de}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
|
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
|
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./images:/data/images
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks:
|
networks:
|
||||||
- hms-network
|
- hms-network
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=hms
|
- POSTGRES_USER=hms
|
||||||
- POSTGRES_PASSWORD=hms
|
- POSTGRES_PASSWORD=hms
|
||||||
- POSTGRES_DB=hms
|
- POSTGRES_DB=hms
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U hms -d hms"]
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- pg_isready -U hms -d hms
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
networks:
|
networks:
|
||||||
- hms-network
|
- hms-network
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test:
|
||||||
|
- CMD
|
||||||
|
- redis-cli
|
||||||
|
- ping
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
networks:
|
networks:
|
||||||
- hms-network
|
- hms-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
hms-network:
|
hms-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
coolify:
|
coolify:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data: null
|
||||||
redis_data:
|
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 {
|
:root {
|
||||||
/* Brand Accent - used sparingly */
|
/* Brand Accent - used sparingly */
|
||||||
--color-accent: #EC6925;
|
--color-accent: #FA5C01;
|
||||||
--color-accent-hover: #d4581a;
|
--color-accent-hover: #d4581a;
|
||||||
--color-accent-light: rgba(236, 105, 37, 0.08);
|
--color-accent-light: rgba(236, 105, 37, 0.08);
|
||||||
--color-accent-border: rgba(236, 105, 37, 0.25);
|
--color-accent-border: rgba(236, 105, 37, 0.25);
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Brand Accent - used sparingly */
|
/* Brand Accent - used sparingly */
|
||||||
--color-accent: #EC6925;
|
--color-accent: #FA5C01;
|
||||||
--color-accent-hover: #d4581a;
|
--color-accent-hover: #d4581a;
|
||||||
--color-accent-light: rgba(236, 105, 37, 0.08);
|
--color-accent-light: rgba(236, 105, 37, 0.08);
|
||||||
--color-accent-border: rgba(236, 105, 37, 0.25);
|
--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>
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ import type { NuxtConfig } from "nuxt/schema";
|
|||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
devtools: { enabled: false },
|
devtools: { enabled: false },
|
||||||
|
ssr: false,
|
||||||
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt"],
|
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt"],
|
||||||
css: ["~/assets/css/main.css"],
|
css: ["~/assets/css/main.css"],
|
||||||
app: {
|
app: {
|
||||||
@@ -59,6 +60,7 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
routeRules: {
|
routeRules: {
|
||||||
|
"/": { ssr: false },
|
||||||
"/mietkatalog": { ssr: true },
|
"/mietkatalog": { ssr: true },
|
||||||
"/mietkatalog/**": { ssr: true },
|
"/mietkatalog/**": { ssr: true },
|
||||||
"/warenkorb": { ssr: false },
|
"/warenkorb": { ssr: false },
|
||||||
@@ -76,6 +78,7 @@ export default defineNuxtConfig({
|
|||||||
nitro: {
|
nitro: {
|
||||||
compressPublicAssets: true,
|
compressPublicAssets: true,
|
||||||
routeRules: {
|
routeRules: {
|
||||||
|
"/": { ssr: false },
|
||||||
"/api/**": {
|
"/api/**": {
|
||||||
proxy: "http://backend:8000/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>
|
||||||
|
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
|
node_modules
|
||||||
.nuxt
|
|
||||||
.output
|
|
||||||
dist
|
dist
|
||||||
.git
|
.git
|
||||||
.gitignore
|
|
||||||
*.md
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
playwright-report
|
|
||||||
test-results
|
|
||||||
coverage
|
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
# --- Stage 1: Build ---
|
# Stage 1: Build
|
||||||
FROM node:20 AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
COPY package.json package-lock.json ./
|
RUN npm ci || npm install
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# --- Stage 2: Production ---
|
# Stage 2: Serve with Nginx
|
||||||
FROM node:20-slim AS production
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
WORKDIR /app
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
COPY --from=builder /app/.output ./.output
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
CMD ["node", ".output/server/index.mjs"]
|
|
||||||
|
|||||||
@@ -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,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"dev": "vite",
|
||||||
"dev": "nuxt dev",
|
"build": "tsc && vite build",
|
||||||
"generate": "nuxt generate",
|
"preview": "vite preview",
|
||||||
"preview": "nuxt preview",
|
"typecheck": "tsc --noEmit"
|
||||||
"postinstall": "nuxt prepare",
|
|
||||||
"typecheck": "nuxt typecheck",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"test": "vitest run --reporter verbose",
|
|
||||||
"test:e2e": "playwright test"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"react": "^18.3.0",
|
||||||
"nuxt": "^3.14.0",
|
"react-dom": "^18.3.0",
|
||||||
"pinia": "^3.0.4",
|
"react-router-dom": "^6.26.0"
|
||||||
"vue": "^3.5.0",
|
|
||||||
"vue-router": "^4.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/test-utils": "^3.14.0",
|
"@types/react": "^18.3.0",
|
||||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@playwright/test": "^1.48.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"@vue/test-utils": "^2.4.5",
|
"autoprefixer": "^10.4.0",
|
||||||
"happy-dom": "^15.0.0",
|
"postcss": "^8.4.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vitest": "^3.2.0",
|
"vite": "^5.4.0"
|
||||||
"vue-tsc": "^2.1.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>
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
export async function apiGet<T>(url: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params) {
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
query.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const qs = query.toString();
|
||||||
|
const fullUrl = `${API_BASE}${url}${qs ? '?' + qs : ''}`;
|
||||||
|
const res = await fetch(fullUrl, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<T>(url: string, body?: unknown, token?: string): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const res = await fetch(`${API_BASE}${url}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPut<T>(url: string, body?: unknown, token?: string): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const res = await fetch(`${API_BASE}${url}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDelete<T>(url: string, token?: string): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const res = await fetch(`${API_BASE}${url}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { apiGet } from './client';
|
||||||
|
import type { EquipmentItem, EquipmentDetail, PaginatedEquipment, SortOption } from '../types';
|
||||||
|
|
||||||
|
export async function fetchEquipment(params: {
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
sort?: SortOption;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}): Promise<PaginatedEquipment> {
|
||||||
|
return apiGet<PaginatedEquipment>('/equipment', {
|
||||||
|
search: params.search || undefined,
|
||||||
|
category: params.category || undefined,
|
||||||
|
sort: params.sort || 'name_asc',
|
||||||
|
page: params.page || 1,
|
||||||
|
page_size: params.page_size || 24,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEquipmentDetail(id: number | string): Promise<EquipmentDetail> {
|
||||||
|
return apiGet<EquipmentDetail>(`/equipment/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCategories(): Promise<string[]> {
|
||||||
|
return apiGet<string[]>('/equipment/categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEquipmentById(id: number | string): Promise<EquipmentItem> {
|
||||||
|
return apiGet<EquipmentItem>(`/equipment/${id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
interface Props {
|
||||||
|
icon?: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyState({ icon = '🔍', title, message, actionLabel, onAction }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16 px-4" role="status">
|
||||||
|
<div className="text-6xl mb-4" aria-hidden="true">{icon}</div>
|
||||||
|
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
|
||||||
|
<p className="max-w-md mx-auto mb-6" style={{ color: 'var(--secondary)' }}>{message}</p>
|
||||||
|
{actionLabel && <button onClick={onAction} className="hms-btn hms-btn-primary">{actionLabel}</button>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { EquipmentItem } from '../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: EquipmentItem & { code?: string; image?: string };
|
||||||
|
onClick: (item: any) => void;
|
||||||
|
onAddToCart: (item: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EquipmentCard({ item, onClick, onAddToCart }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="hms-eq-card" onClick={() => onClick(item)} role="article" tabIndex={0} onKeyDown={e => e.key === 'Enter' && onClick(item)}>
|
||||||
|
<div className="aspect-[4/3] flex items-center justify-center relative overflow-hidden" style={{ background: 'var(--surface)' }}>
|
||||||
|
{item.image ? (
|
||||||
|
<img src={item.image} alt={item.name} loading="lazy" className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="text-4xl" style={{ color: 'var(--secondary)' }}>📦</div>
|
||||||
|
)}
|
||||||
|
{item.category && <span className="hms-badge hms-badge-primary absolute top-3 left-3">{item.category}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="font-semibold text-sm mb-1 truncate" style={{ color: 'var(--text)' }}>{item.name}</h3>
|
||||||
|
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--secondary)' }}>{item.description}</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs" style={{ color: 'var(--secondary)' }}>{item.code}</span>
|
||||||
|
<button onClick={e => { e.stopPropagation(); onAddToCart(item); }} className="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style={{ color: 'var(--color-accent)' }} aria-label={item.name + ' zum Warenkorb hinzufügen'}>+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorState({ title = 'Ein Fehler ist aufgetreten', message, onRetry }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16 px-4" role="alert">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ background: 'var(--color-error-bg)', color: 'var(--color-error)' }}>
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
|
||||||
|
<p className="max-w-md mx-auto mb-6" style={{ color: 'var(--secondary)' }}>{message}</p>
|
||||||
|
<button onClick={onRetry} className="hms-btn hms-btn-primary">Erneut versuchen</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Logo from './Logo';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
return (
|
||||||
|
<footer className="hms-footer mt-16" role="contentinfo">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-4" style={{ color: 'var(--text)' }}>
|
||||||
|
<Logo size={36} />
|
||||||
|
<div><div className="font-bold text-sm">HMS Licht & Ton</div><div className="text-xs" style={{ color: 'var(--secondary)' }}>Veranstaltungstechnik</div></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed" style={{ color: 'var(--secondary)' }}>Hammerschmidt u. Mössle GbR – seit über 20 Jahren Ihr Partner für professionelle Veranstaltungstechnik in der Region Leipheim / Ellzee.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Navigation</h3>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li><Link to="/" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Home</Link></li>
|
||||||
|
<li><Link to="/referenzen" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Referenzen</Link></li>
|
||||||
|
<li><Link to="/mietkatalog" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Mietkatalog</Link></li>
|
||||||
|
<li><Link to="/kontakt" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Kontakt</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Kontakt</h3>
|
||||||
|
<ul className="space-y-2 text-sm" style={{ color: 'var(--secondary)' }}>
|
||||||
|
<li>Grockelhofen 10, 89340 Leipheim</li>
|
||||||
|
<li><a href="tel:+498221204433" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">+49 (0) 8221 / 204433</a></li>
|
||||||
|
<li><a href="mailto:info@hms-licht-ton.de" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">info@hms-licht-ton.de</a></li>
|
||||||
|
<li className="flex gap-3 pt-2">
|
||||||
|
<a href="https://facebook.com" target="_blank" rel="noopener" aria-label="Facebook" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]"><svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
|
||||||
|
<a href="https://instagram.com" target="_blank" rel="noopener" aria-label="Instagram" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]"><svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg></a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Rechtliches</h3>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li><Link to="/impressum" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Impressum</Link></li>
|
||||||
|
<li><Link to="/datenschutz" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">DSGVO</Link></li>
|
||||||
|
<li><Link to="/agb-vermietung" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">AGB Vermietung</Link></li>
|
||||||
|
<li><Link to="/admin" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)] text-xs">Admin-Login</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t mt-8 pt-6 text-center text-xs" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}>
|
||||||
|
© {year} Hammerschmidt u. Mössle GbR · Veranstaltungstechnik · Leipheim / Ellzee
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import Logo from './Logo';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Home', to: '/' },
|
||||||
|
{ label: 'Referenzen', to: '/referenzen' },
|
||||||
|
{ label: 'Mietkatalog', to: '/mietkatalog' },
|
||||||
|
{ label: 'Kontakt', to: '/kontakt' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
function isActive(to: string): boolean {
|
||||||
|
if (to === '/') return location.pathname === '/';
|
||||||
|
return location.pathname.startsWith(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="hms-header" role="banner">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between" style={{ height: 'var(--header-height)' }}>
|
||||||
|
<Link to="/" className="flex items-center gap-2" style={{ color: 'var(--text)' }} aria-label="HMS Licht & Ton Startseite">
|
||||||
|
<Logo size={40} />
|
||||||
|
<div className="hidden sm:block leading-tight">
|
||||||
|
<div className="font-bold text-base" style={{ color: 'var(--text)' }}>HMS Licht & Ton</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--secondary)' }}>Veranstaltungstechnik</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="Hauptnavigation">
|
||||||
|
{navItems.map(item => (
|
||||||
|
<Link key={item.to} to={item.to}
|
||||||
|
className="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
|
style={isActive(item.to) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }}
|
||||||
|
aria-current={isActive(item.to) ? 'page' : undefined}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<Link to="/warenkorb" className="hms-btn hms-btn-primary text-sm ml-2 px-4 py-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||||
|
Warenkorb
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<button onClick={() => setMobileMenuOpen(!mobileMenuOpen)} className="md:hidden p-2 rounded-lg" style={{ color: 'var(--text-muted)' }} aria-expanded={mobileMenuOpen} aria-label="Menü öffnen/schließen">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{!mobileMenuOpen ?
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"/> :
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<nav id="mobile-menu" className="md:hidden border-t" style={{ borderColor: 'var(--border)', background: 'var(--panel)' }} role="navigation" aria-label="Mobile Navigation">
|
||||||
|
<div className="px-4 py-3 space-y-1">
|
||||||
|
{navItems.map(item => (
|
||||||
|
<Link key={item.to} to={item.to} onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="hms-nav-btn block w-full text-left px-4 py-3 rounded-lg text-sm font-medium"
|
||||||
|
style={isActive(item.to) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<Link to="/warenkorb" onClick={() => setMobileMenuOpen(false)} className="block w-full text-left px-4 py-3 rounded-lg text-sm font-medium text-white" style={{ background: 'var(--color-accent)' }}>🛒 Warenkorb</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||