v3.-funktioniert
This commit is contained in:
parent
787aab5e1d
commit
63c93fa7e4
9 changed files with 336 additions and 76 deletions
31
app/components/EmptyState.tsx
Normal file
31
app/components/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyState - Zeigt eine Nachricht an, wenn keine Services konfiguriert sind
|
||||||
|
*/
|
||||||
|
export function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<svg
|
||||||
|
className="w-16 h-16 text-slate-400 mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
Keine Dienste konfiguriert
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mt-2 text-center max-w-md">
|
||||||
|
Füge Dienste in der Konfigurationsdatei <code className="bg-slate-200 dark:bg-slate-700 px-2 py-1 rounded text-sm">src/data/services.ts</code> hinzu, um sie hier anzuzeigen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
app/components/FilterBar.tsx
Normal file
114
app/components/FilterBar.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ServiceCategory, ServiceStatus } from '@/lib/data';
|
||||||
|
|
||||||
|
interface FilterBarProps {
|
||||||
|
categories: ServiceCategory[];
|
||||||
|
statuses: ServiceStatus[];
|
||||||
|
selectedCategories: ServiceCategory[];
|
||||||
|
selectedStatuses: ServiceStatus[];
|
||||||
|
onCategoryChange: (category: ServiceCategory) => void;
|
||||||
|
onStatusChange: (status: ServiceStatus) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FilterBar - Filterleiste zum Filtern von Services nach Kategorie und Status
|
||||||
|
* @param categories - Verfügbare Kategorien
|
||||||
|
* @param statuses - Verfügbare Status
|
||||||
|
* @param selectedCategories - Aktuell ausgewählte Kategorien
|
||||||
|
* @param selectedStatuses - Aktuell ausgewählte Status
|
||||||
|
* @param onCategoryChange - Callback beim Ändern einer Kategorie
|
||||||
|
* @param onStatusChange - Callback beim Ändern eines Status
|
||||||
|
* @param onReset - Callback zum Zurücksetzen aller Filter
|
||||||
|
*/
|
||||||
|
export function FilterBar({
|
||||||
|
categories,
|
||||||
|
statuses,
|
||||||
|
selectedCategories,
|
||||||
|
selectedStatuses,
|
||||||
|
onCategoryChange,
|
||||||
|
onStatusChange,
|
||||||
|
onReset,
|
||||||
|
}: FilterBarProps) {
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedCategories.length > 0 || selectedStatuses.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8 p-4 sm:p-6 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 shadow-sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Kategorie Filter */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-3">
|
||||||
|
Kategorie
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => onCategoryChange(category)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
selectedCategories.includes(category)
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
aria-pressed={selectedCategories.includes(category)}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-3">
|
||||||
|
Status
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statuses.map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => onStatusChange(status)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-2 ${
|
||||||
|
selectedStatuses.includes(status)
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
aria-pressed={selectedStatuses.includes(status)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
status === 'online'
|
||||||
|
? 'bg-green-500'
|
||||||
|
: status === 'warning'
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{status === 'online' && 'Online'}
|
||||||
|
{status === 'warning' && 'Warnung'}
|
||||||
|
{status === 'offline' && 'Offline'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Button */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Filter zurücksetzen
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 flex items-center">
|
||||||
|
{selectedCategories.length + selectedStatuses.length} Filter aktiv
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,28 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
export function Header() {
|
interface HeaderProps {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header - Hauptkopfzeile des Dashboards
|
||||||
|
* @param title - Der Titel des Headers (Standard: "Homelab Dashboard")
|
||||||
|
* @param subtitle - Der Untertitel des Headers (Standard: "Übersicht aller verwalteten Dienste")
|
||||||
|
*/
|
||||||
|
export function Header({
|
||||||
|
title = 'Homelab Dashboard',
|
||||||
|
subtitle = 'Übersicht aller verwalteten Dienste'
|
||||||
|
}: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="bg-gradient-to-r from-slate-900 to-slate-800 border-b border-slate-700 shadow-lg">
|
<header className="bg-gradient-to-r from-slate-900 to-slate-800 border-b border-slate-700 shadow-lg">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
|
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
|
||||||
Homelab Dashboard
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-400 text-sm">
|
<p className="text-slate-400 text-sm">
|
||||||
Übersicht aller verwalteten Dienste
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ interface ServiceCardProps {
|
||||||
service: Service;
|
service: Service;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig = {
|
const STATUS_CONFIG = {
|
||||||
online: {
|
online: {
|
||||||
bgColor: 'bg-green-50 dark:bg-green-950',
|
bgColor: 'bg-green-50 dark:bg-green-950',
|
||||||
textColor: 'text-green-700 dark:text-green-300',
|
textColor: 'text-green-700 dark:text-green-300',
|
||||||
|
|
@ -25,10 +25,14 @@ const statusConfig = {
|
||||||
dotColor: 'bg-red-500',
|
dotColor: 'bg-red-500',
|
||||||
label: 'Offline',
|
label: 'Offline',
|
||||||
},
|
},
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceCard - Zeigt einen einzelnen Service mit Status, Icon und Öffnen-Button
|
||||||
|
* @param service - Das Service-Objekt mit Name, Beschreibung, Status, etc.
|
||||||
|
*/
|
||||||
export function ServiceCard({ service }: ServiceCardProps) {
|
export function ServiceCard({ service }: ServiceCardProps) {
|
||||||
const config = statusConfig[service.status];
|
const config = STATUS_CONFIG[service.status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
|
@ -39,21 +43,31 @@ export function ServiceCard({ service }: ServiceCardProps) {
|
||||||
>
|
>
|
||||||
<div className="h-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden hover:border-slate-300 dark:hover:border-slate-600">
|
<div className="h-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden hover:border-slate-300 dark:hover:border-slate-600">
|
||||||
<div className="p-6 flex flex-col gap-4 h-full">
|
<div className="p-6 flex flex-col gap-4 h-full">
|
||||||
{/* Header mit Name und Status */}
|
{/* Header mit Icon, Name, Kategorie und Status */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 flex items-center gap-3">
|
<div className="flex-1 flex items-center gap-3">
|
||||||
{service.icon && <span className="text-2xl">{service.icon}</span>}
|
{service.icon && (
|
||||||
<div>
|
<span className="text-2xl flex-shrink-0" aria-hidden="true">
|
||||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
{service.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
|
||||||
{service.name}
|
{service.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
|
||||||
{service.category}
|
{service.category}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center gap-2 px-3 py-1 rounded-full ${config.bgColor}`}>
|
|
||||||
<span className={`w-2 h-2 rounded-full ${config.dotColor} animate-pulse`} />
|
{/* Status Badge */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 px-3 py-1 rounded-full whitespace-nowrap flex-shrink-0 ${config.bgColor}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${config.dotColor} animate-pulse`}
|
||||||
|
/>
|
||||||
<span className={`text-xs font-medium ${config.textColor}`}>
|
<span className={`text-xs font-medium ${config.textColor}`}>
|
||||||
{config.label}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -61,35 +75,35 @@ export function ServiceCard({ service }: ServiceCardProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Beschreibung */}
|
{/* Beschreibung */}
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400 flex-grow">
|
<p className="text-sm text-slate-600 dark:text-slate-400 flex-grow line-clamp-2">
|
||||||
{service.description}
|
{service.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Button */}
|
{/* Öffnen Button */}
|
||||||
<div>
|
<button
|
||||||
<button
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
window.open(service.url, '_blank');
|
||||||
window.open(service.url, '_blank');
|
}}
|
||||||
}}
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2 group/btn"
|
||||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2 group/btn"
|
aria-label={`${service.name} öffnen`}
|
||||||
|
>
|
||||||
|
Öffnen
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 group-hover/btn:translate-x-1 transition-transform"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
Öffnen
|
<path
|
||||||
<svg
|
strokeLinecap="round"
|
||||||
className="w-4 h-4 group-hover/btn:translate-x-1 transition-transform"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
strokeWidth={2}
|
||||||
stroke="currentColor"
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
>
|
</svg>
|
||||||
<path
|
</button>
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
27
app/components/ServiceGrid.tsx
Normal file
27
app/components/ServiceGrid.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Service } from '@/lib/data';
|
||||||
|
import { ServiceCard } from './ServiceCard';
|
||||||
|
import { EmptyState } from './EmptyState';
|
||||||
|
|
||||||
|
interface ServiceGridProps {
|
||||||
|
services: Service[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceGrid - Rendert Services in einem responsiven Grid-Layout
|
||||||
|
* @param services - Array von Service-Objekten
|
||||||
|
*/
|
||||||
|
export function ServiceGrid({ services }: ServiceGridProps) {
|
||||||
|
if (services.length === 0) {
|
||||||
|
return <EmptyState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{services.map((service) => (
|
||||||
|
<ServiceCard key={service.id} service={service} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
app/components/ServiceSection.tsx
Normal file
84
app/components/ServiceSection.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Service, ServiceCategory, ServiceStatus } from '@/lib/data';
|
||||||
|
import { FilterBar } from './FilterBar';
|
||||||
|
import { ServiceGrid } from './ServiceGrid';
|
||||||
|
|
||||||
|
interface ServiceSectionProps {
|
||||||
|
services: Service[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceSection - Verwaltet Filterlogik und rendert FilterBar + ServiceGrid
|
||||||
|
* @param services - Array aller verfügbaren Services
|
||||||
|
*/
|
||||||
|
export function ServiceSection({ services }: ServiceSectionProps) {
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<
|
||||||
|
ServiceCategory[]
|
||||||
|
>([]);
|
||||||
|
const [selectedStatuses, setSelectedStatuses] = useState<ServiceStatus[]>([]);
|
||||||
|
|
||||||
|
// Extrahiere eindeutige Kategorien und Status aus Services
|
||||||
|
const categories = useMemo(
|
||||||
|
() => Array.from(new Set(services.map((s) => s.category))).sort(),
|
||||||
|
[services]
|
||||||
|
);
|
||||||
|
|
||||||
|
const statuses = useMemo(
|
||||||
|
() =>
|
||||||
|
(['online', 'warning', 'offline'] as ServiceStatus[]).filter((status) =>
|
||||||
|
services.some((s) => s.status === status)
|
||||||
|
),
|
||||||
|
[services]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filtere Services basierend auf ausgewählten Filtern
|
||||||
|
const filteredServices = useMemo(() => {
|
||||||
|
return services.filter((service) => {
|
||||||
|
const categoryMatch =
|
||||||
|
selectedCategories.length === 0 ||
|
||||||
|
selectedCategories.includes(service.category);
|
||||||
|
|
||||||
|
const statusMatch =
|
||||||
|
selectedStatuses.length === 0 ||
|
||||||
|
selectedStatuses.includes(service.status);
|
||||||
|
|
||||||
|
return categoryMatch && statusMatch;
|
||||||
|
});
|
||||||
|
}, [services, selectedCategories, selectedStatuses]);
|
||||||
|
|
||||||
|
const handleCategoryChange = (category: ServiceCategory) => {
|
||||||
|
setSelectedCategories((prev) =>
|
||||||
|
prev.includes(category)
|
||||||
|
? prev.filter((c) => c !== category)
|
||||||
|
: [...prev, category]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = (status: ServiceStatus) => {
|
||||||
|
setSelectedStatuses((prev) =>
|
||||||
|
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSelectedCategories([]);
|
||||||
|
setSelectedStatuses([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilterBar
|
||||||
|
categories={categories}
|
||||||
|
statuses={statuses}
|
||||||
|
selectedCategories={selectedCategories}
|
||||||
|
selectedStatuses={selectedStatuses}
|
||||||
|
onCategoryChange={handleCategoryChange}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
onReset={handleReset}
|
||||||
|
/>
|
||||||
|
<ServiceGrid services={filteredServices} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/components/index.ts
Normal file
9
app/components/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
// Re-export all components from this directory
|
||||||
|
export { Header } from './Header';
|
||||||
|
export { ServiceCard } from './ServiceCard';
|
||||||
|
export { ServiceGrid } from './ServiceGrid';
|
||||||
|
export { ServiceSection } from './ServiceSection';
|
||||||
|
export { FilterBar } from './FilterBar';
|
||||||
|
export { EmptyState } from './EmptyState';
|
||||||
37
app/page.tsx
37
app/page.tsx
|
|
@ -1,6 +1,5 @@
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Header } from './components/Header';
|
import { Header, ServiceSection } from './components';
|
||||||
import { ServiceCard } from './components/ServiceCard';
|
|
||||||
import { services } from '@/lib/data';
|
import { services } from '@/lib/data';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
|
|
@ -13,39 +12,9 @@ export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
||||||
{/* Services Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
||||||
{services.map((service) => (
|
|
||||||
<ServiceCard key={service.id} service={service} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Empty State (falls keine Services) */}
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
{services.length === 0 && (
|
<ServiceSection services={services} />
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<svg
|
|
||||||
className="w-16 h-16 text-slate-400 mb-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">
|
|
||||||
Keine Dienste konfiguriert
|
|
||||||
</h3>
|
|
||||||
<p className="text-slate-600 dark:text-slate-400 mt-2">
|
|
||||||
Füge Dienste in der Datenkonfiguration hinzu, um sie hier anzuzeigen
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import type { Config } from 'postcss-load-config'
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
const config: Config = {
|
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
Loading…
Add table
Reference in a new issue