fix: 1:1 prototype port – exact hms-* CSS, real logo, all components from app.js

This commit is contained in:
Implementation Engineer
2026-07-10 22:28:26 +02:00
parent 786cb0c040
commit 90a7d7f2e0
38 changed files with 2733 additions and 3250 deletions
+44 -117
View File
@@ -1,128 +1,55 @@
<template>
<div>
<h1 class="text-3xl font-bold text-text mb-2">Referenzen</h1>
<p class="text-text-muted mb-8">Ein Auszug aus unseren Projekten und Veranstaltungen.</p>
<!-- Filter Chips -->
<div class="flex flex-wrap gap-3 mb-8" data-testid="filter-chips">
<button
v-for="cat in categories"
:key="cat"
:class="[
'px-4 py-2 rounded-full text-sm font-medium border transition-colors duration-fast',
activeFilter === cat
? 'bg-accent text-white border-accent'
: 'bg-panel text-text-muted border-border-default hover:border-accent-border'
]"
:data-testid="'filter-' + cat.toLowerCase()"
@click="activeFilter = cat"
>
{{ cat }}
</button>
<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)">Referenzen</h1>
<p class="mb-8" style="color: var(--secondary)">Ausgewählte Projekte aus unserer Veranstaltungstechnik-Praxis.</p>
<div class="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="Referenz-Filter">
<button v-for="cat in categories" :key="cat" @click="filter = cat" :class="['hms-chip', filter === cat ? 'active' : '']" role="tab" :aria-selected="filter === cat">{{ cat === 'alle' ? 'Alle' : cat }}</button>
</div>
<!-- Gallery Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" data-testid="gallery-grid">
<div
v-for="item in filteredImages"
:key="item.id"
class="relative group cursor-pointer rounded-lg overflow-hidden bg-panel border border-border-default hover:border-accent-border transition-colors duration-fast"
:data-testid="'gallery-item-' + item.id"
@click="openLightbox(item)"
>
<div class="aspect-w-4 aspect-h-3 w-full">
<img
:src="item.src"
:alt="item.alt"
class="w-full h-48 object-cover group-hover:opacity-80 transition-opacity duration-fast"
loading="lazy"
/>
</div>
<div class="p-4">
<p class="text-accent text-sm font-bold mb-1">{{ item.title }}</p>
<p class="text-text-muted text-xs">{{ item.category }}</p>
<div v-if="loading">
<div class="hms-gallery">
<div v-for="i in 6" :key="i" class="hms-card overflow-hidden">
<div class="hms-skeleton aspect-[4/3]" style="border-radius:0"></div>
<div class="p-4 space-y-2"><div class="hms-skeleton h-4 w-3/4"></div><div class="hms-skeleton h-3 w-1/3"></div></div>
</div>
</div>
</div>
<!-- Lightbox -->
<Lightbox
:is-open="lightboxOpen"
:image="currentImage"
@close="closeLightbox"
@prev="prevImage"
@next="nextImage"
/>
<ErrorState v-else-if="error" title="Referenzen konnten nicht geladen werden" message="Bitte versuchen Sie es in Kürze erneut." @retry="retry" />
<div v-else class="hms-gallery">
<article v-for="img in filteredImages" :key="img.id" class="hms-card overflow-hidden group cursor-pointer">
<div class="aspect-[4/3] relative overflow-hidden">
<img :src="img.img" :alt="img.title" loading="lazy" class="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<span class="hms-badge hms-badge-primary absolute top-3 left-3">{{ img.category }}</span>
</div>
<div class="p-4"><h3 class="font-semibold text-sm" style="color: var(--text)">{{ img.title }}</h3><p class="text-xs mt-1" style="color: var(--secondary)">{{ img.date }}</p></div>
</article>
</div>
<EmptyState v-if="!loading && !error && filteredImages.length === 0" icon="📭" title="Keine Referenzen" message="Für diese Kategorie liegen aktuell keine Referenzen vor." action-label="Alle anzeigen" @action="filter = 'alle'" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
useHead({
title: "Referenzen HMS Licht & Ton",
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
});
interface GalleryItem {
id: number;
src: string;
alt: string;
title: string;
category: string;
caption: string;
}
const categories = ["Alle", "Open-Air", "Indoor", "Rigging"] as const;
const activeFilter = ref<string>("Alle");
const images: GalleryItem[] = [
{ id: 1, src: "https://picsum.photos/seed/hms1/600/400", alt: "Open-Air Konzert", title: "Stadtfest Leipheim", category: "Open-Air", caption: "Stadtfest Leipheim Beschallung und Lichttechnik" },
{ id: 2, src: "https://picsum.photos/seed/hms2/600/400", alt: "Indoor Firmenevent", title: "Firmenevent Augsburg", category: "Indoor", caption: "Firmenevent in Augsburg Komplett-Setup" },
{ id: 3, src: "https://picsum.photos/seed/hms3/600/400", alt: "Rigging Aufbau", title: "Traversen-Rigging", category: "Rigging", caption: "Traversen-Rigging in einer Mehrzweckhalle" },
{ id: 4, src: "https://picsum.photos/seed/hms4/600/400", alt: "Open-Air Festival", title: "Sommerfest Günzburg", category: "Open-Air", caption: "Sommerfest Günzburg Line-Array Beschallung" },
{ id: 5, src: "https://picsum.photos/seed/hms5/600/400", alt: "Indoor Konferenz", title: "Konferenz Neu-Ulm", category: "Indoor", caption: "Konferenz in Neu-Ulm Mikrofonie und Mischtechnik" },
{ id: 6, src: "https://picsum.photos/seed/hms6/600/400", alt: "Rigging Motorzug", title: "Motorzug-System", category: "Rigging", caption: "Motorzug-System für Bühnenbeleuchtung" },
{ id: 7, src: "https://picsum.photos/seed/hms7/600/400", alt: "Open-Air Bühne", title: "Kirchweih Festzelt", category: "Open-Air", caption: "Kirchweih Festzelt Beschallung und Beleuchtung" },
{ id: 8, src: "https://picsum.photos/seed/hms8/600/400", alt: "Indoor Gala", title: "Gala Leipheim", category: "Indoor", caption: "Gala-Abend in Leipheim Licht- und Tontechnik" },
{ id: 9, src: "https://picsum.photos/seed/hms9/600/400", alt: "Rigging Seilzug", title: "Punkt-Rigging", category: "Rigging", caption: "Punkt-Rigging für Moving Heads" },
];
import { ref, computed, onMounted } from 'vue'
const loading = ref(true)
const error = ref(false)
const filter = ref('alle')
const categories = ['alle', 'Open-Air', 'Indoor', 'Rigging', 'Event']
const allImages = [
{ id: 1, title: 'Open-Air Veranstaltung', category: 'Open-Air', date: '2017', img: 'img/ref1.jpg' },
{ id: 2, title: 'Bühnenaufbau', category: 'Rigging', date: '2016', img: 'img/ref2.jpg' },
{ id: 3, title: 'Donautal Radelspass', category: 'Event', date: '2019', img: 'img/ref3.jpg' },
{ id: 4, title: 'Traversenaufbau', category: 'Rigging', date: '2019', img: 'img/ref4.jpg' },
{ id: 5, title: 'Veranstaltungstechnik vor Ort', category: 'Event', date: '2019', img: 'img/ref5.jpg' },
{ id: 6, title: 'Indoor Beschallung', category: 'Indoor', date: '2016', img: 'img/ref6.jpg' },
{ id: 7, title: 'Bühnentechnik Setup', category: 'Rigging', date: '2019', img: 'img/ref7.jpg' },
{ id: 8, title: 'Event-Aufbau', category: 'Event', date: '2019', img: 'img/ref8.jpg' },
{ id: 9, title: 'Großbühne Aufbau', category: 'Open-Air', date: '2016', img: 'img/ref9.jpg' }
]
const filteredImages = computed(() => {
if (activeFilter.value === "Alle") return images;
return images.filter((img) => img.category === activeFilter.value);
});
const lightboxOpen = ref(false);
const currentIndex = ref(0);
const currentImage = computed<GalleryItem>(() => {
const item = filteredImages.value[currentIndex.value];
return item
? { src: item.src, alt: item.alt, caption: item.caption }
: { src: "", alt: "", caption: "" };
});
function openLightbox(item: GalleryItem) {
const idx = filteredImages.value.findIndex((i) => i.id === item.id);
if (idx >= 0) {
currentIndex.value = idx;
lightboxOpen.value = true;
}
}
function closeLightbox() {
lightboxOpen.value = false;
}
function nextImage() {
currentIndex.value = (currentIndex.value + 1) % filteredImages.value.length;
}
function prevImage() {
currentIndex.value =
currentIndex.value === 0
? filteredImages.value.length - 1
: currentIndex.value - 1;
}
</script>
if (filter.value === 'alle') return allImages
return allImages.filter(i => i.category === filter.value)
})
onMounted(() => { setTimeout(() => { loading.value = false }, 1000) })
function retry() { loading.value = true; error.value = false; setTimeout(() => { loading.value = false }, 800) }
</script>