feat: add MCP server with resources, tools, auth, and Traefik routing
- Add backend/app/mcp_server.py with FastMCP server (hms-cms) - Resources: design-rules, page-structure, sync-status - Tools: trigger_sync, get_sync_log, set_rentman_token, read_file, write_file, create_page, deploy, git_commit, git_status - Bearer token auth via MCP_AUTH_TOKEN env var - Mount MCP at /mcp with streamable HTTP transport - Integrate session manager in FastAPI lifespan - Add Traefik labels for external /mcp route on hms.media-on.de - Add git, docker.io, curl to backend Dockerfile - Mount repo root + docker socket in backend container - Add mcp>=1.0.0 to requirements.txt
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<a href="#main-content" className="skip-link">Zum Hauptinhalt springen</a>
|
||||
<Header />
|
||||
<main id="main-content" className="flex-1" role="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
content?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function LegalContentPage({ title, content = '', loading = false }: Props) {
|
||||
useEffect(() => {
|
||||
document.title = `${title} – HMS Licht & Ton`;
|
||||
}, [title]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="hms-skeleton rounded-md" style={{ height: 32, width: '60%' }} />
|
||||
<div className="hms-skeleton rounded-md" style={{ height: 20 }} />
|
||||
<div className="hms-skeleton rounded-md" style={{ height: 20, width: '90%' }} />
|
||||
<div className="hms-skeleton rounded-md" style={{ height: 20 }} />
|
||||
<div className="hms-skeleton rounded-md" style={{ height: 20, width: '80%' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold text-text mb-6">{title}</h1>
|
||||
<div className="prose prose-invert max-w-none text-text-muted" dangerouslySetInnerHTML={{ __html: content }} />
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export default function LoadingSkeleton({ count = 6 }: Props) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" aria-live="polite" aria-busy="true">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="hms-card overflow-hidden">
|
||||
<div className="hms-skeleton aspect-[4/3]" style={{ borderRadius: 0 }}></div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="hms-skeleton h-4 w-3/4"></div>
|
||||
<div className="hms-skeleton h-3 w-full"></div>
|
||||
<div className="hms-skeleton h-3 w-1/2"></div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div className="hms-skeleton h-3 w-20"></div>
|
||||
<div className="hms-skeleton h-6 w-24" style={{ borderRadius: 'var(--radius-md)' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function Logo({ size = 40 }: LogoProps) {
|
||||
return (
|
||||
<svg className="hms-logo-svg" viewBox="0 0 200 200" style={{ height: `${size}px`, width: 'auto' }} aria-label="HMS Licht & Ton Logo" role="img">
|
||||
<path fill="currentColor" d="M166.266,172.872h-37.687v-60.718H74.226v60.718H36.539V26.956h37.687v56.335h54.354V26.956h37.687V172.872z" />
|
||||
<rect x="23.598" y="15.343" fill="none" stroke="#FA5C01" strokeWidth="15.1525" strokeMiterlimit="10" width="155.612" height="168.874" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
interface Props {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function ServiceCard({ icon, title, description }: Props) {
|
||||
return (
|
||||
<div className="hms-card p-6">
|
||||
<div className="hms-icon-circle mb-4"><span aria-hidden="true">{icon}</span></div>
|
||||
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
linearray: '<rect x="8" y="2" width="16" height="40" rx="2"/><circle cx="16" cy="10" r="3"/><circle cx="16" cy="22" r="4"/><circle cx="16" cy="34" r="3"/>',
|
||||
subwoofer: '<rect x="4" y="8" width="24" height="20" rx="3"/><circle cx="16" cy="18" r="7"/><circle cx="16" cy="18" r="3"/>',
|
||||
monitor: '<path d="M4 6h20v14H4z"/><path d="M8 24h12"/><circle cx="14" cy="13" r="3"/>',
|
||||
pointsource: '<circle cx="16" cy="16" r="12"/><circle cx="16" cy="16" r="6"/><circle cx="16" cy="16" r="2"/>',
|
||||
column: '<rect x="10" y="2" width="12" height="44" rx="2"/><circle cx="16" cy="8" r="2"/><circle cx="16" cy="16" r="2"/><circle cx="16" cy="24" r="2"/><circle cx="16" cy="32" r="2"/><circle cx="16" cy="40" r="2"/>',
|
||||
movinghead: '<rect x="10" y="4" width="12" height="16" rx="2"/><path d="M16 20v8"/><rect x="8" y="28" width="16" height="4" rx="1"/>'
|
||||
};
|
||||
|
||||
export default function SpeakerIcon({ type = 'linearray' }: Props) {
|
||||
const svgStr = useMemo(() =>
|
||||
'<svg class="hms-speaker-icon" viewBox="0 0 32 48" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">' +
|
||||
(icons[type] || icons.linearray) +
|
||||
'</svg>', [type]);
|
||||
return <span className="hms-speaker-icon-wrap" dangerouslySetInnerHTML={{ __html: svgStr }} />;
|
||||
}
|
||||
Reference in New Issue
Block a user