v3.-funktioniert

This commit is contained in:
Bilal Teke 2026-04-15 21:41:48 +02:00
parent 787aab5e1d
commit 63c93fa7e4
9 changed files with 336 additions and 76 deletions

View 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>
);
}

View 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>
);
}

View file

@ -1,15 +1,28 @@
'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 (
<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="flex flex-col gap-2">
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
Homelab Dashboard
{title}
</h1>
<p className="text-slate-400 text-sm">
Übersicht aller verwalteten Dienste
{subtitle}
</p>
</div>
</div>

View file

@ -6,7 +6,7 @@ interface ServiceCardProps {
service: Service;
}
const statusConfig = {
const STATUS_CONFIG = {
online: {
bgColor: 'bg-green-50 dark:bg-green-950',
textColor: 'text-green-700 dark:text-green-300',
@ -25,10 +25,14 @@ const statusConfig = {
dotColor: 'bg-red-500',
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) {
const config = statusConfig[service.status];
const config = STATUS_CONFIG[service.status];
return (
<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="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-1 flex items-center gap-3">
{service.icon && <span className="text-2xl">{service.icon}</span>}
<div>
<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 className="text-2xl flex-shrink-0" aria-hidden="true">
{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}
</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}
</p>
</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}`}>
{config.label}
</span>
@ -61,35 +75,35 @@ export function ServiceCard({ service }: ServiceCardProps) {
</div>
{/* 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}
</p>
{/* Button */}
<div>
<button
onClick={(e) => {
e.preventDefault();
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"
{/* Öffnen Button */}
<button
onClick={(e) => {
e.preventDefault();
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"
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
<svg
className="w-4 h-4 group-hover/btn:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
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>
<path
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>
</a>

View 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>
);
}

View 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
View 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';

View file

@ -1,6 +1,5 @@
import type { Metadata } from 'next';
import { Header } from './components/Header';
import { ServiceCard } from './components/ServiceCard';
import { Header, ServiceSection } from './components';
import { services } from '@/lib/data';
import './globals.css';
@ -15,37 +14,7 @@ export default function Home() {
<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) */}
{services.length === 0 && (
<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>
)}
<ServiceSection services={services} />
</main>
</div>
);

View file

@ -1,6 +1,5 @@
import type { Config } from 'postcss-load-config'
const config: Config = {
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},