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';
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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 { Header } from './components/Header';
|
||||
import { ServiceCard } from './components/ServiceCard';
|
||||
import { Header, ServiceSection } from './components';
|
||||
import { services } from '@/lib/data';
|
||||
import './globals.css';
|
||||
|
||||
|
|
@ -13,39 +12,9 @@ export default function Home() {
|
|||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
<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>
|
||||
)}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<ServiceSection services={services} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import type { Config } from 'postcss-load-config'
|
||||
|
||||
const config: Config = {
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
export default config
|
||||
Loading…
Add table
Reference in a new issue