Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a269aa54f | |||
| 30db3491c0 | |||
| 8dae2c0301 | |||
| 16fde9fb7d |
@@ -9,3 +9,4 @@ htmlcov/
|
||||
node_modules/
|
||||
.nuxt/
|
||||
dist/
|
||||
images/
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
> **Projekt:** hms-licht-ton (ID 30)
|
||||
> **Stack:** Nuxt 3 (Frontend) + FastAPI (Backend) + PostgreSQL + Redis
|
||||
> **Deployment:** Coolify (Docker) auf coolify-01
|
||||
>
|
||||
> **DESIGN-RICHTLINIEN:** `docs/design-rules.md` – VERBINDLICH für alle Erweiterungen!
|
||||
> Jeder KI-Agent MUSS diese Regeln vor der Implementierung lesen und einhalten.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+19
-3
@@ -7,7 +7,20 @@ services:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE:-http://backend:8000}
|
||||
- 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))"]
|
||||
@@ -17,11 +30,10 @@ services:
|
||||
start_period: 15s
|
||||
networks:
|
||||
- hms-network
|
||||
- coolify
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -40,6 +52,8 @@ services:
|
||||
- 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')"]
|
||||
@@ -85,6 +99,8 @@ services:
|
||||
networks:
|
||||
hms-network:
|
||||
driver: bridge
|
||||
coolify:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
# HMS Licht & Ton – Design-Richtlinien für KI-Agenten
|
||||
|
||||
> **VERBINDLICH:** Diese Regeln MÜSSEN bei jeder Erweiterung der Website eingehalten werden.
|
||||
> Abweichungen sind Fehler. Der freigegebene Prototyp ist die Quelle der Wahrheit.
|
||||
> Prototyp: `https://webspace.media-on.de/hms-prototype/index.html?v=8`
|
||||
> Referenz-Dateien: `docs/prototype-app.js`, `docs/prototype-index.html`, `frontend/assets/css/prototype-style.css`
|
||||
|
||||
---
|
||||
|
||||
## 1. Farbsystem (Dark Theme + Orange Akzent)
|
||||
|
||||
### Hauptfarben (Agent Zero Dark Theme Grautöne)
|
||||
| Token | Hex | Verwendung |
|
||||
|-------|-----|------------|
|
||||
| `--bg` | `#131313` | Seiten-Hintergrund |
|
||||
| `--panel` | `#1a1a1a` | Panels, Cards, Header |
|
||||
| `--surface` | `#212121` | Input-Felder, Surface-Level |
|
||||
| `--row` | `#272727` | List-Items, Zwischenebenen |
|
||||
| `--card` | `#2d2d2d` | Card-Hintergrund |
|
||||
| `--border` | `#444444a8` | Borders (mit Alpha) |
|
||||
| `--border-strong` | `#555555` | Strong Borders (Inputs) |
|
||||
| `--secondary` | `#656565` | Sekundärfarbe, Icons, grauer Text |
|
||||
| `--primary` | `#737a81` | Primärfarbe (leicht bläuliches Grau) |
|
||||
| `--text` | `#ffffff` | Haupttext |
|
||||
| `--text-muted` | `#d4d4d4` | Gedimmter Text |
|
||||
|
||||
### Orange Akzent (DEZENT – keine großen Flächen!)
|
||||
| Token | Wert | Verwendung |
|
||||
|-------|------|------------|
|
||||
| `--color-accent` | `#EC6925` | Buttons, Badges, Text-Akzente, Logo-Border |
|
||||
| `--color-accent-hover` | `#d4581a` | Button Hover |
|
||||
| `--color-accent-light` | `rgba(236, 105, 37, 0.08)` | Badge-Hintergrund, Nav-Active |
|
||||
| `--color-accent-border` | `rgba(236, 105, 37, 0.25)` | Card Hover Border |
|
||||
| `--color-accent-dark` | `#b8461a` | Dunkle Variante |
|
||||
|
||||
### Status-Farben
|
||||
| Token | Hex/RGBA | Verwendung |
|
||||
|-------|----------|------------|
|
||||
| `--color-success` | `#4ade80` | Erfolg-Meldungen |
|
||||
| `--color-success-bg` | `rgba(74, 222, 128, 0.08)` | Erfolg-Hintergrund |
|
||||
| `--color-error` | `#f87171` | Fehler, Pflichtfelder |
|
||||
| `--color-error-bg` | `rgba(248, 113, 113, 0.08)` | Fehler-Hintergrund |
|
||||
| `--color-warning` | `#fbbf24` | Warnungen |
|
||||
| `--color-info` | `#60a5fa` | Info |
|
||||
|
||||
### FARB-REGELN
|
||||
- **Orange NIEMALS als große Fläche** – nur für Buttons, Badges, kleine Text-Highlights, Border
|
||||
- **Hintergründe IMMER Grautöne** (#131313 bis #2d2d2d)
|
||||
- **Text IMMER `var(--text)` (#ffffff) oder `var(--text-muted)` (#d4d4d4)**
|
||||
- **Sekundärer Text `var(--secondary)` (#656565)** für sehr gedimmte Infos
|
||||
- **Inline Styles mit CSS Variablen:** `style="color: var(--text)"`, `style="background: var(--panel)"`
|
||||
|
||||
---
|
||||
|
||||
## 2. Typography
|
||||
- **Font Family:** Inter (Google Fonts), Weights: 400, 500, 600, 700, 800
|
||||
- **Größen:** Tailwind Utilities (`text-xs`, `text-sm`, `text-base`, `text-lg`, `text-xl`, `text-2xl`, `text-3xl`, `text-4xl`, `text-5xl`, `text-6xl`)
|
||||
- **Headings:** `font-bold` oder `font-semibold`, `leading-tight` für große Headlines
|
||||
- **Body:** `leading-relaxed` für längere Texte
|
||||
- **Line Height:** 1.6 (Body), 1.1-1.3 (Headings)
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout-System
|
||||
|
||||
### Container
|
||||
- **Max-Width:** `max-w-7xl` (1280px)
|
||||
- **Padding:** `px-4 sm:px-6 lg:px-8`
|
||||
- **Center:** `mx-auto`
|
||||
|
||||
### Sections
|
||||
- **Vertical Padding:** `py-12 sm:py-16` (Standard), `py-16 sm:py-20` (große Sections), `py-20 sm:py-32` (Hero)
|
||||
- **Background Alternation:** `var(--bg)` → `var(--panel)` → `var(--bg)` für visuelle Trennung
|
||||
- **Section Dividers:** `.hms-section-divider` (subtiler Gradient)
|
||||
|
||||
### Grid
|
||||
- **Cards Grid:** `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6`
|
||||
- **Speaker Grid:** `.hms-speaker-grid` (auto-fill, minmax 160px)
|
||||
- **Gallery Grid:** `.hms-gallery` (auto-fill, minmax 300px)
|
||||
- **2-Spalten Layout:** `grid lg:grid-cols-2 gap-8` oder `gap-12`
|
||||
|
||||
### Responsive Breakpoints
|
||||
- `sm:` 640px (Tablet Portrait)
|
||||
- `md:` 768px (Desktop Nav visible, Mobile Burger hidden)
|
||||
- `lg:` 1024px (Desktop, 3-4 Column Grids)
|
||||
- **Mobile-First:** Alle Layouts starten 1-Spalte
|
||||
|
||||
---
|
||||
|
||||
## 4. CSS Komponenten-Klassen (hms-*)
|
||||
|
||||
### ÜBERSICHT: Alle hms-* Klassen aus prototype-style.css
|
||||
Diese Klassen sind in `assets/css/main.css` definiert und MÜSSEN verwendet werden.
|
||||
Keine eigenen Klassen erfinden!
|
||||
|
||||
### Buttons
|
||||
```html
|
||||
<!-- Primary: Orange bg, white text -->
|
||||
<button class="hms-btn hms-btn-primary">Button Text</button>
|
||||
|
||||
<!-- Secondary: Surface bg, muted text, border -->
|
||||
<button class="hms-btn hms-btn-secondary">Button Text</button>
|
||||
|
||||
<!-- Ghost: Transparent, muted text -->
|
||||
<button class="hms-btn hms-btn-ghost">Button Text</button>
|
||||
```
|
||||
- **Padding:** `px-8 py-4` für große Buttons, `px-4 py-2` für kleine, `px-3 py-1.5` für Mini
|
||||
- **Font:** `font-semibold` oder `font-medium`, `text-sm` für Standard
|
||||
- **Hover-Effekte:** Primary → box-shadow glow + translateY(-1px); Secondary → border accent
|
||||
- **Disabled:** `--secondary` bg, `cursor: not-allowed`
|
||||
|
||||
### Cards
|
||||
```html
|
||||
<div class="hms-card p-6">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">Title</h3>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--text-muted)">Description</p>
|
||||
</div>
|
||||
```
|
||||
- **Background:** `var(--panel)` (#1a1a1a)
|
||||
- **Border:** `1px solid var(--border)` (#444444a8)
|
||||
- **Border-Radius:** `4px` (--radius-lg)
|
||||
- **Hover:** Box-shadow wird stärker, Border wird heller
|
||||
|
||||
### Equipment Cards
|
||||
```html
|
||||
<div class="hms-eq-card" @click="..." role="article" tabindex="0">
|
||||
<div class="aspect-[4/3] flex items-center justify-center relative overflow-hidden" style="background: var(--surface)">
|
||||
<img v-if="item.image" :src="item.image" class="absolute inset-0 w-full h-full object-cover" />
|
||||
<div v-else class="text-4xl" style="color: var(--secondary)">📦</div>
|
||||
<span v-if="item.category" class="hms-badge hms-badge-primary absolute top-3 left-3">{{ item.category }}</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-sm mb-1 truncate" style="color: var(--text)">{{ item.name }}</h3>
|
||||
<p class="text-xs mb-3 line-clamp-2" style="color: var(--secondary)">{{ item.description }}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs" style="color: var(--secondary)">{{ item.code }}</span>
|
||||
<button class="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style="color: var(--color-accent)">+ Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Badges
|
||||
```html
|
||||
<span class="hms-badge hms-badge-primary">Badge Text</span>
|
||||
<span class="hms-badge hms-badge-gray">Gray Badge</span>
|
||||
<span class="hms-badge hms-badge-success">Success</span>
|
||||
<span class="hms-badge hms-badge-error">Error</span>
|
||||
```
|
||||
- **Border-Radius:** `9999px` (pill shape)
|
||||
- **Primary:** Orange bg (12% alpha), orange text, orange border (20% alpha)
|
||||
|
||||
### Inputs
|
||||
```html
|
||||
<input class="hms-input" type="text" placeholder="..." />
|
||||
<input class="hms-input hms-input-error" /> <!-- mit Fehler -->
|
||||
<select class="hms-input"><option>...</option></select>
|
||||
<textarea class="hms-input" rows="5"></textarea>
|
||||
```
|
||||
- **Background:** `var(--surface)` (#212121)
|
||||
- **Border:** `1px solid var(--border-strong)` (#555555)
|
||||
- **Focus:** Orange border + subtle orange box-shadow
|
||||
- **Error:** Red border + red box-shadow
|
||||
- **Padding:** `var(--space-md) var(--space-lg)`
|
||||
|
||||
### Filter Chips
|
||||
```html
|
||||
<button class="hms-chip" :class="{ active: isActive }">Filter</button>
|
||||
```
|
||||
- **Inactive:** Surface bg, muted text, border
|
||||
- **Active:** Orange bg, white text, orange border
|
||||
|
||||
### Navigation Buttons
|
||||
```html
|
||||
<button class="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
|
||||
:style="isActive ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }"
|
||||
:aria-current="isActive ? 'page' : undefined">
|
||||
Nav Item
|
||||
</button>
|
||||
```
|
||||
- **Hover:** Orange text + accent-light bg + Unterstrich-Animation
|
||||
- **Active:** Orange text + accent-light bg
|
||||
|
||||
### Skeleton Loading
|
||||
```html
|
||||
<div class="hms-skeleton h-4 w-3/4"></div>
|
||||
```
|
||||
- Shimmer Animation von `prototype-style.css`
|
||||
|
||||
### Spinner
|
||||
```html
|
||||
<span class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span>
|
||||
```
|
||||
|
||||
### Icon Circle (Service Icons)
|
||||
```html
|
||||
<div class="hms-icon-circle mb-4"><span aria-hidden="true">🔊</span></div>
|
||||
```
|
||||
- 48x48px, surface bg, border, centered content
|
||||
|
||||
### Hero
|
||||
```html
|
||||
<section class="hms-hero py-20 sm:py-32">
|
||||
<div class="hms-hero-bg" style="background-image: url('...')"></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">
|
||||
<!-- Content -->
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
- **hms-hero-bg:** Absolute, background-size cover, opacity 0.35
|
||||
- **hms-hero-overlay:** Linear gradient von rgba(19,19,19,0.6) zu var(--bg)
|
||||
- **Content:** `relative z-10` über Overlay
|
||||
|
||||
---
|
||||
|
||||
## 5. Inline Style Pattern
|
||||
|
||||
### REGEL: Inline Styles verwenden IMMER CSS Variablen
|
||||
```html
|
||||
<!-- RICHTIG -->
|
||||
<h1 style="color: var(--text)">Title</h1>
|
||||
<p style="color: var(--text-muted)">Text</p>
|
||||
<div :style="{ background: 'var(--panel)', borderColor: 'var(--border)' }">
|
||||
|
||||
<!-- FALSCH -->
|
||||
<h1 style="color: #ffffff">Title</h1>
|
||||
<p style="color: #d4d4d4">Text</p>
|
||||
```
|
||||
|
||||
### Häufige Inline Styles aus dem Prototyp
|
||||
- `style="color: var(--text)"` – Haupttext
|
||||
- `style="color: var(--text-muted)"` – Gedimmter Text
|
||||
- `style="color: var(--secondary)"` – Sehr gedimmter Text
|
||||
- `style="color: var(--color-accent)"` – Orange Akzent
|
||||
- `style="background: var(--panel)"` – Panel BG
|
||||
- `style="background: var(--bg)"` – Seiten BG
|
||||
- `style="background: var(--surface)"` – Surface BG
|
||||
- `:style="{ borderColor: 'var(--border)' }"` – Border Color
|
||||
- `:style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }"` – Success State
|
||||
|
||||
---
|
||||
|
||||
## 6. Komponenten-Struktur
|
||||
|
||||
### Bestehende Komponenten (NICHT neu erfinden)
|
||||
| Komponente | Datei | Zweck |
|
||||
|---|---|---|
|
||||
| HmsLogo | `components/HmsLogo.vue` | Echtes SVG Logo (H + orange Border) |
|
||||
| SpeakerIcon | `components/SpeakerIcon.vue` | 6 Lautsprecher-Typ SVG Icons |
|
||||
| AppHeader | `components/AppHeader.vue` | Sticky Header, Nav, Cart, Mobile Burger |
|
||||
| AppFooter | `components/AppFooter.vue` | 4-Spalten Footer |
|
||||
| ServiceCard | `components/ServiceCard.vue` | Service Card mit Icon Circle |
|
||||
| EquipmentCard | `components/EquipmentCard.vue` | Equipment Card mit Badge, Image, Add-to-Cart |
|
||||
| LoadingSkeleton | `components/LoadingSkeleton.vue` | Skeleton Loading Cards |
|
||||
| EmptyState | `components/EmptyState.vue` | Empty State mit Icon + Action |
|
||||
| ErrorState | `components/ErrorState.vue` | Error State mit Retry |
|
||||
|
||||
### Neue Komponenten erstellen – Checklist
|
||||
1. CSS Klassen aus `prototype-style.css` verwenden (hms-*)
|
||||
2. Inline Styles mit CSS Variablen
|
||||
3. Tailwind Utilities für Layout/Spacing
|
||||
4. TypeScript mit `lang="ts"`
|
||||
5. `useHead()` für Meta Tags
|
||||
6. ARIA Attributes (role, aria-label, aria-current, etc.)
|
||||
7. Responsive (mobile-first, sm/md/lg breakpoints)
|
||||
|
||||
---
|
||||
|
||||
## 7. UX States (MUSS in jeder View implementiert)
|
||||
|
||||
### Loading State
|
||||
```html
|
||||
<loading-skeleton :count="6" />
|
||||
```
|
||||
|
||||
### Empty State
|
||||
```html
|
||||
<empty-state icon="🔍" title="Keine Ergebnisse" message="..." action-label="Zurück" @action="..." />
|
||||
```
|
||||
|
||||
### Error State
|
||||
```html
|
||||
<error-state title="Fehler" message="..." @retry="..." />
|
||||
```
|
||||
|
||||
### Success State
|
||||
```html
|
||||
<div class="text-center py-8" role="status">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4"
|
||||
:style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }">
|
||||
<svg class="w-8 h-8" ...>✓</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">Erfolg</h3>
|
||||
<p class="text-sm mb-6" style="color: var(--secondary)">Nachricht</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Submitting State
|
||||
```html
|
||||
<button :disabled="submitting" class="hms-btn hms-btn-primary">
|
||||
<span v-if="submitting" class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span>
|
||||
<span v-else>Senden</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility (WCAG 2.1 AA)
|
||||
|
||||
### MUSS-Regeln
|
||||
- **Skip Link:** `<a class="skip-link" href="#main">Zum Inhalt springen</a>` am Seitenanfang
|
||||
- **Semantic HTML:** `<header>`, `<main>`, `<footer>`, `<nav>`, `<section>`, `<article>`
|
||||
- **ARIA:** `role`, `aria-label`, `aria-current="page"`, `aria-expanded`, `aria-invalid`, `aria-describedby`, `role="alert"`, `role="status"`
|
||||
- **Focus Visible:** `*:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }`
|
||||
- **Form Labels:** Jedes Input hat `<label for="...">`, Pflichtfelder mit `<span style="color: var(--color-error)">*</span>`
|
||||
- **Heading Hierarchy:** h1 > h2 > h3, keine Levels überspringen
|
||||
- **Keyboard Navigation:** Alle interaktiven Elemente per Tab erreichbar, `tabindex="0"` auf klickbare Cards
|
||||
- **Alt Text:** Alle Bilder haben `alt="..."`
|
||||
- `aria-hidden="true"` auf dekorativen Icons
|
||||
- `loading="lazy"` auf alle Bilder
|
||||
|
||||
---
|
||||
|
||||
## 9. Meta Tags & SEO
|
||||
|
||||
### MUSS pro Seite
|
||||
```typescript
|
||||
useHead({
|
||||
title: 'Seite – HMS Licht & Ton',
|
||||
meta: [
|
||||
{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet' },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### JSON-LD (nur Home)
|
||||
- `LocalBusiness` Schema mit Adresse, Öffnungszeiten, Services
|
||||
|
||||
### OpenGraph
|
||||
- `og:type`, `og:title`, `og:description`, `og:locale`, `og:site_name`, `og:url`
|
||||
|
||||
### REGELN
|
||||
- **noindex** auf ALLEN Seiten (Domain nicht öffentlich indexiert)
|
||||
- **KEINE sitemap.xml**
|
||||
- Inter Font via Google Fonts preconnect
|
||||
- `theme-color: #EC6925` (nur im Head, nicht als Design-Fläche!)
|
||||
|
||||
---
|
||||
|
||||
## 10. Bilder
|
||||
|
||||
### Hero Background
|
||||
- Unsplash Konzert-Bild: `background-image: url('https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=1920&q=80')`
|
||||
- **Opacity:** 0.35 (über `.hms-hero-bg`)
|
||||
- **Overlay:** Linear gradient zu `var(--bg)`
|
||||
|
||||
### Referenzen
|
||||
- 9 echte HMS-Fotos: `img/ref1.jpg` bis `img/ref9.jpg`
|
||||
- **Hover:** `group-hover:scale-110` (Zoom-Effekt)
|
||||
- **Gradient Overlay:** `bg-gradient-to-t from-black/50 to-transparent` bei Hover
|
||||
|
||||
### Equipment
|
||||
- Platzhalter `📦` Emoji wenn kein Bild vorhanden
|
||||
- `loading="lazy"` auf allen Bildern
|
||||
|
||||
### About Section
|
||||
- Unsplash Setup-Bild: `https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&q=80`
|
||||
|
||||
---
|
||||
|
||||
## 11. Spacing & Radius
|
||||
|
||||
### Spacing Tokens
|
||||
| Token | Wert |
|
||||
|-------|-------|
|
||||
| `--space-xs` | 0.25rem (4px) |
|
||||
| `--space-sm` | 0.5rem (8px) |
|
||||
| `--space-md` | 1rem (16px) |
|
||||
| `--space-lg` | 1.5rem (24px) |
|
||||
| `--space-xl` | 2rem (32px) |
|
||||
| `--space-2xl` | 3rem (48px) |
|
||||
| `--space-3xl` | 4rem (64px) |
|
||||
| `--space-4xl` | 6rem (96px) |
|
||||
|
||||
### Border Radius (MINIMAL!)
|
||||
| Token | Wert |
|
||||
|-------|-------|
|
||||
| `--radius-sm` | 2px |
|
||||
| `--radius-md` | 3px |
|
||||
| `--radius-lg` | 4px |
|
||||
| `--radius-xl` | 4px |
|
||||
| `--radius-full` | 9999px (nur Badges, Pills) |
|
||||
|
||||
### Shadows
|
||||
| Token | Wert |
|
||||
|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px 0 rgb(0 0 0 / 0.3)` |
|
||||
| `--shadow-md` | `0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)` |
|
||||
| `--shadow-lg` | `0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)` |
|
||||
| `--shadow-xl` | `0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3)` |
|
||||
|
||||
---
|
||||
|
||||
## 12. Transitions
|
||||
| Token | Dauer |
|
||||
|-------|-------|
|
||||
| `--transition-fast` | 150ms ease |
|
||||
| `--transition-base` | 250ms ease |
|
||||
| `--transition-slow` | 400ms ease |
|
||||
|
||||
---
|
||||
|
||||
## 13. Build & Test
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd frontend && npm run build # Build (MUSS 0 errors)
|
||||
cd frontend && npx vitest run # Unit Tests (MUSS alle pass)
|
||||
cd frontend && npx playwright test --grep 'Pattern' # E2E Tests
|
||||
curl -s http://localhost:3000/ # Smoke Test
|
||||
```
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend && python -m pytest tests/ -v --cov=app # Tests (MUSS alle pass)
|
||||
curl -s http://localhost:8000/api/health # Health Check
|
||||
```
|
||||
|
||||
### Deployment
|
||||
- Server: coolify-01 (46.225.91.159)
|
||||
- Pfad: `/data/hms-licht-ton/`
|
||||
- Branch: `feature/T04-rentman-integration` (oder `main` nach Merge)
|
||||
- Docker Compose: `docker compose up -d`
|
||||
- Domain: `https://hms.media-on.de` (Traefik + Let's Encrypt)
|
||||
|
||||
---
|
||||
|
||||
## 14. VERBOTEN
|
||||
- ❌ Eigene CSS Klassen erfinden (nur `hms-*` verwenden)
|
||||
- ❌ Generische `.btn-primary` oder `.nav-link` Klassen
|
||||
- ❌ Hardcoded Hex-Werte in Inline Styles (immer `var(--...)`)
|
||||
- ❌ Fake-Logo (immer echtes SVG aus `assets/logo.svg`)
|
||||
- ❌ Große Orange-Flächen
|
||||
- ❌ Abgerundete Ecken > 4px (außer Badges/Pills = 9999px)
|
||||
- ❌ Client-only rendering für SSR Pages (`/mietkatalog`, `/mietkatalog/**`)
|
||||
- ❌ Fehlende ARIA Attributes
|
||||
- ❌ Fehlende UX States (Loading, Empty, Error, Success)
|
||||
- ❌ Fehlendes `noindex` Meta Tag
|
||||
- ❌ Eigene Templates die nicht dem Prototyp entsprechen
|
||||
|
||||
---
|
||||
|
||||
## 15. Referenz-Dateien
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `docs/prototype-app.js` | Original Vue 3 SPA Prototyp (761 Zeilen, 19 Komponenten) |
|
||||
| `docs/prototype-index.html` | Original HTML mit Meta Tags, JSON-LD |
|
||||
| `frontend/assets/css/prototype-style.css` | Original CSS mit allen hms-* Klassen (13KB) |
|
||||
| `frontend/assets/css/main.css` | Aktive CSS (enthält prototype-style.css + Tailwind directives) |
|
||||
| `frontend/assets/logo.svg` | Echtes HMS Logo (503 bytes, weißes H + orange Border) |
|
||||
| `frontend/public/logo.svg` | Static copy für Nuxt public dir |
|
||||
| `docs/ui_design.md` | Original UI Design Dokumentation |
|
||||
| `docs/design-rules.md` | DIES DATEI – Design Richtlinien für KI-Agenten |
|
||||
|
||||
---
|
||||
|
||||
## 16. Schnell-Referenz: Neue Seite erstellen
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Seiten-Titel</h1>
|
||||
<p class="mb-8" style="color: var(--secondary)">Untertitel</p>
|
||||
|
||||
<!-- Loading -->
|
||||
<loading-skeleton v-if="loading" :count="6" />
|
||||
|
||||
<!-- Error -->
|
||||
<error-state v-else-if="error" title="Fehler" message="..." @retry="loadData" />
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div v-for="item in items" :key="item.id" class="hms-card p-6">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">{{ item.title }}</h3>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--text-muted)">{{ item.description }}</p>
|
||||
<button class="hms-btn hms-btn-primary mt-4">Aktion</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<empty-state v-if="!loading && !error && items.length === 0"
|
||||
icon="🔍" title="Keine Daten" message="..." action-label="Zurück" @action="..." />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Seite – HMS Licht & Ton',
|
||||
meta: [{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet' }],
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Dieses Dokument ist die verbindliche Design-Richtlinie für alle Erweiterungen der HMS Licht & Ton Website.*
|
||||
@@ -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: {
|
||||
|
||||
@@ -51,9 +51,11 @@ 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: {
|
||||
@@ -73,5 +75,10 @@ export default defineNuxtConfig({
|
||||
},
|
||||
nitro: {
|
||||
compressPublicAssets: true,
|
||||
routeRules: {
|
||||
"/api/**": {
|
||||
proxy: "http://backend:8000/api/**",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,61 +6,85 @@
|
||||
</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 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 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 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 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 { ref, watch } from 'vue'
|
||||
import { useEquipment } from '~/composables/useEquipment'
|
||||
import { useCart } from '~/composables/useCart'
|
||||
|
||||
const { list, categories: fetchCategories } = useEquipment()
|
||||
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
|
||||
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 {}
|
||||
})
|
||||
onMounted(() => { setTimeout(() => { loading.value = false }, 1000) })
|
||||
|
||||
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)
|
||||
@@ -69,13 +93,12 @@ 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
|
||||
image_url: item.image_url || null
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<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>
|
||||
<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" :src="item.image" :alt="item.name" class="absolute inset-0 w-full h-full object-cover" />
|
||||
<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 class="hms-badge hms-badge-primary absolute top-4 left-4 text-sm">{{ item.category }}</span>
|
||||
<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.code }}</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 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 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>
|
||||
@@ -25,59 +25,28 @@
|
||||
</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 { 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 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)
|
||||
})
|
||||
const { data: item, pending, error } = await useAsyncData(
|
||||
'equipment-detail',
|
||||
() => detail(route.params.id as string)
|
||||
)
|
||||
|
||||
function navigate(route: string) {
|
||||
navigateTo(route)
|
||||
@@ -85,19 +54,11 @@ function navigate(route: string) {
|
||||
}
|
||||
function addToCart() {
|
||||
addItemByFields({
|
||||
equipment_id: item.value.id,
|
||||
name: item.value.name,
|
||||
equipment_id: item.value!.id,
|
||||
name: item.value!.name,
|
||||
rental_price: null,
|
||||
image_url: item.value.image || null
|
||||
image_url: item.value!.image_url || null
|
||||
})
|
||||
navigate('/warenkorb')
|
||||
}
|
||||
function addToCartSimple(e: any) {
|
||||
addItemByFields({
|
||||
equipment_id: e.id,
|
||||
name: e.name,
|
||||
rental_price: null,
|
||||
image_url: e.image || null
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user