feat: industrial precision dark theme redesign
- Dark server-room slate with amber/copper status-light accents - DM Sans body + JetBrains Mono for data/mono content - Glass sidebar with noise grain texture - Staggered fade-in animations across all pages - Amber glow on interactions, refined border system - New favicon (amber clock on dark bg)
This commit is contained in:
+4
-1
@@ -1,10 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>UpdatePlaner</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+5
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 383 B |
+29
-11
@@ -2,18 +2,36 @@ interface BadgeProps {
|
||||
status: 'pending' | 'confirmed' | 'cancelled';
|
||||
}
|
||||
|
||||
const colors = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
confirmed: 'bg-green-100 text-green-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const labels = {
|
||||
pending: 'Ausstehend',
|
||||
confirmed: 'Bestätigt',
|
||||
cancelled: 'Storniert',
|
||||
const config = {
|
||||
pending: {
|
||||
bg: 'bg-amber-500/10',
|
||||
text: 'text-amber-400',
|
||||
dot: 'bg-amber-400',
|
||||
label: 'Ausstehend',
|
||||
animate: true,
|
||||
},
|
||||
confirmed: {
|
||||
bg: 'bg-green-500/10',
|
||||
text: 'text-green-400',
|
||||
dot: 'bg-green-400',
|
||||
label: 'Bestätigt',
|
||||
animate: false,
|
||||
},
|
||||
cancelled: {
|
||||
bg: 'bg-red-500/10',
|
||||
text: 'text-red-400',
|
||||
dot: 'bg-red-400',
|
||||
label: 'Storniert',
|
||||
animate: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function Badge({ status }: BadgeProps) {
|
||||
return <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colors[status]}`}>{labels[status]}</span>;
|
||||
const c = config[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot} ${c.animate ? 'animate-pulse' : ''}`} />
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,29 @@ import { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', className = '', ...props }: ButtonProps) {
|
||||
const base = 'px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 cursor-pointer';
|
||||
const variants = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||
ghost: 'text-gray-600 hover:bg-gray-100',
|
||||
export function Button({ variant = 'primary', size = 'md', className = '', ...props }: ButtonProps) {
|
||||
const base = 'inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg';
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
};
|
||||
return <button className={`${base} ${variants[variant]} ${className}`} {...props} />;
|
||||
const variants = {
|
||||
primary:
|
||||
'bg-amber-500/10 text-amber-400 border border-amber-500/20 hover:bg-amber-500/20 hover:border-amber-500/30 active:bg-amber-500/25',
|
||||
secondary:
|
||||
'bg-white/5 text-white/70 border border-white/10 hover:bg-white/10 hover:text-white/90 active:bg-white/[0.12]',
|
||||
danger:
|
||||
'bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 active:bg-red-500/25',
|
||||
ghost:
|
||||
'text-white/50 hover:text-white/80 hover:bg-white/5 active:bg-white/[0.08]',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,12 @@ interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', ...props }: CardProps) {
|
||||
return <div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`} {...props}>{children}</div>;
|
||||
return (
|
||||
<div
|
||||
className={`bg-white/[0.03] border border-white/[0.06] rounded-xl backdrop-blur-sm ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,28 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, className = '', ...props }, ref) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <label className="text-sm font-medium text-gray-700">{label}</label>}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label className="text-xs font-medium text-white/50 uppercase tracking-wider">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${error ? 'border-red-500' : 'border-gray-300'} ${className}`}
|
||||
className={`
|
||||
w-full px-3 py-2.5 text-sm
|
||||
bg-white/5 border rounded-lg
|
||||
text-white/90 placeholder:text-white/25
|
||||
transition-all duration-200
|
||||
focus:outline-none focus:border-amber-500/40 focus:bg-white/[0.07] focus:ring-1 focus:ring-amber-500/20
|
||||
${error ? 'border-red-500/40' : 'border-white/10 hover:border-white/20'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||
{error && (
|
||||
<span className="text-xs text-red-400/80">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -17,11 +17,23 @@ export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-lg mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none cursor-pointer">×</button>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative bg-[#181b28] border border-white/[0.08] rounded-2xl shadow-2xl p-6 w-full max-w-lg animate-fade-in"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-base font-semibold text-white/90">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/30 hover:text-white/60 transition-colors text-lg leading-none cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { authClient } from '@/lib/auth-client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
export function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -28,17 +27,48 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<h1 className="text-2xl font-bold mb-1">UpdatePlaner</h1>
|
||||
<p className="text-gray-500 mb-6 text-sm">IT/AM Login</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input label="Email" type="email" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
<Input label="Passwort" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<Button type="submit" disabled={loading}>{loading ? 'Lädt...' : 'Anmelden'}</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<div className="min-h-screen flex items-center justify-center bg-noise" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="absolute inset-0 bg-noise pointer-events-none" />
|
||||
<div className="relative w-full max-w-sm mx-auto p-6">
|
||||
<div className="text-center mb-8 animate-fade-in">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-amber-500/10 border border-amber-500/20 mb-4">
|
||||
<svg className="w-6 h-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white/90">UpdatePlaner</h1>
|
||||
<p className="text-sm text-white/40 mt-1">IT/AM Login</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-6 animate-fade-in stagger-1">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="admin@example.de"
|
||||
/>
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-400/90 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={loading} className="w-full mt-1">
|
||||
{loading ? 'Lädt...' : 'Anmelden'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,16 @@ export function ProtectedRoute() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-gray-500">Lädt...</div>;
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
|
||||
<p className="text-sm text-white/40">Lädt...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!session) return <Navigate to="/login" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { Booking } from '@/types';
|
||||
|
||||
const filters = [
|
||||
{ value: '', label: 'Alle' },
|
||||
{ value: 'pending', label: 'Ausstehend' },
|
||||
{ value: 'confirmed', label: 'Bestätigt' },
|
||||
{ value: 'cancelled', label: 'Storniert' },
|
||||
];
|
||||
|
||||
export function BookingsPage() {
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [filter, setFilter] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: bookings, isLoading } = useQuery({
|
||||
@@ -25,58 +30,93 @@ export function BookingsPage() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['bookings'] }),
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{ value: '', label: 'Alle' },
|
||||
{ value: 'pending', label: 'Ausstehend' },
|
||||
{ value: 'confirmed', label: 'Bestätigt' },
|
||||
{ value: 'cancelled', label: 'Storniert' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-6">Buchungen</h2>
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-semibold text-white/90">Buchungen</h1>
|
||||
<p className="text-sm text-white/40 mt-0.5">Alle Kundenbuchungen verwalten</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
<div className="flex gap-1.5 mb-6 p-1 bg-white/[0.03] rounded-lg border border-white/[0.06] w-fit">
|
||||
{filters.map(f => (
|
||||
<Button
|
||||
<button
|
||||
key={f.value}
|
||||
variant={filter === f.value ? 'primary' : 'ghost'}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 cursor-pointer ${
|
||||
filter === f.value
|
||||
? 'bg-amber-500/10 text-amber-400 border border-amber-500/10'
|
||||
: 'text-white/40 hover:text-white/70 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-gray-500">Lädt...</p>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{bookings?.map((b: any) => (
|
||||
<Card key={b.id}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{b.customer_name}</h3>
|
||||
<p className="text-sm text-gray-500">{b.customer_email}</p>
|
||||
{b.customer_company && <p className="text-sm text-gray-500">{b.customer_company}{b.customer_location ? ` – ${b.customer_location}` : ''}</p>}
|
||||
<p className="text-sm mt-2">
|
||||
<strong>{b.slot_title}</strong> – {b.slot_date} ({b.start_time} - {b.end_time})
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Gebucht: {new Date(b.booked_at).toLocaleString('de-DE')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge status={b.status} />
|
||||
<div className="grid gap-3">
|
||||
{bookings?.map((b: any, i: number) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="animate-fade-in"
|
||||
style={{ animationDelay: `${i * 0.04}s` }}
|
||||
>
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-white/[0.04] border border-white/[0.06] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-white/40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white/90">{b.customer_name}</span>
|
||||
<Badge status={b.status} />
|
||||
</div>
|
||||
<p className="text-xs text-white/40 mt-0.5">{b.customer_email}</p>
|
||||
{(b.customer_company || b.customer_location) && (
|
||||
<p className="text-xs text-white/30 mt-0.5">
|
||||
{[b.customer_company, b.customer_location].filter(Boolean).join(' – ')}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs font-mono text-white/50 bg-white/[0.04] px-2 py-0.5 rounded border border-white/[0.06]">
|
||||
{b.slot_title}
|
||||
</span>
|
||||
<span className="text-xs text-white/30">·</span>
|
||||
<span className="text-xs font-mono text-white/40">{b.slot_date}</span>
|
||||
<span className="text-xs text-white/30">·</span>
|
||||
<span className="text-xs font-mono text-white/40">{b.start_time}–{b.end_time}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-white/25 mt-1.5">
|
||||
Gebucht: {new Date(b.booked_at).toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{b.status === 'pending' && (
|
||||
<>
|
||||
<Button onClick={() => confirmMutation.mutate(b.id)}>Bestätigen</Button>
|
||||
<Button variant="danger" onClick={() => cancelMutation.mutate(b.id)}>Stornieren</Button>
|
||||
</>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button size="sm" onClick={() => confirmMutation.mutate(b.id)}>
|
||||
Bestätigen
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => cancelMutation.mutate(b.id)}>
|
||||
Stornieren
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
{bookings?.length === 0 && <p className="text-gray-400 text-center py-8">Keine Buchungen</p>}
|
||||
{(!bookings || bookings.length === 0) && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-sm text-white/30">Keine Buchungen</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
export function BookingConfirmation() {
|
||||
@@ -14,24 +13,44 @@ export function BookingConfirmation() {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 p-4"><p className="text-gray-500">Lädt...</p></div>;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4">
|
||||
<div className="max-w-md mx-auto pt-12">
|
||||
<Card>
|
||||
<h2 className="text-xl font-bold mb-1">Buchungsstatus</h2>
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="max-w-md mx-auto pt-20 p-4 animate-fade-in">
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-6">
|
||||
<h2 className="text-base font-semibold text-white/90 mb-5">Buchungsstatus</h2>
|
||||
{data && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p><strong>{data.slot_title}</strong></p>
|
||||
<p className="text-sm text-gray-500">{data.slot_date} | {data.start_time} - {data.end_time}</p>
|
||||
<p className="text-sm text-gray-500">{data.customer_name} – {data.customer_email}</p>
|
||||
<div className="mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-amber-500/5 border border-amber-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-amber-400/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/90">{data.slot_title}</p>
|
||||
<p className="text-xs font-mono text-white/40 mt-0.5">
|
||||
{data.slot_date} · {data.start_time} – {data.end_time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-white/[0.06] pt-3 space-y-1.5">
|
||||
<p className="text-xs text-white/40">{data.customer_name} – {data.customer_email}</p>
|
||||
</div>
|
||||
<div className="border-t border-white/[0.06] pt-3 flex items-center gap-2">
|
||||
<span className="text-xs text-white/40">Status:</span>
|
||||
<Badge status={data.status} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from 'zod';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import type { PublicSlot } from '@/types';
|
||||
|
||||
const bookingSchema = z.object({
|
||||
@@ -42,62 +41,97 @@ export function BookingPage() {
|
||||
|
||||
if (successBooking) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||
<Card className="max-w-md text-center">
|
||||
<h2 className="text-xl font-bold mb-2">Buchung eingegangen</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Ihre Buchung für <strong>{successBooking.slot_title}</strong> am {successBooking.slot_date} ({successBooking.start_time} - {successBooking.end_time}) wurde registriert.
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-8 max-w-md text-center animate-fade-in">
|
||||
<div className="w-14 h-14 rounded-full bg-green-500/10 border border-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/90 mb-2">Buchung eingegangen</h2>
|
||||
<p className="text-sm text-white/50 mb-4">
|
||||
Ihre Buchung für <strong className="text-white/70">{successBooking.slot_title}</strong> am{' '}
|
||||
{successBooking.slot_date} ({successBooking.start_time} – {successBooking.end_time}) wurde registriert.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Status: <strong>Ausstehend</strong> – Sie erhalten eine Email sobald bestätigt.</p>
|
||||
</Card>
|
||||
<p className="text-xs text-white/30">Status: <span className="text-amber-400 font-medium">Ausstehend</span></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-1">Update-Termin buchen</h1>
|
||||
<p className="text-gray-500 mb-6">Wählen Sie einen freien Slot</p>
|
||||
<div className="min-h-screen p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="max-w-2xl mx-auto pt-12 animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-semibold text-white/90">Update-Termin buchen</h1>
|
||||
<p className="text-sm text-white/40 mt-1">Wählen Sie einen verfügbaren Slot</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-gray-500">Lädt...</p>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 mb-8">
|
||||
{data?.slots?.map((slot: PublicSlot) => (
|
||||
<Card
|
||||
{data?.slots?.map((slot: PublicSlot, i: number) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={`cursor-pointer transition-colors ${selectedSlot === slot.id ? 'ring-2 ring-blue-500' : ''} ${!slot.available ? 'opacity-50' : ''}`}
|
||||
onClick={() => slot.available && setSelectedSlot(slot.id)}
|
||||
className="animate-fade-in"
|
||||
style={{ animationDelay: `${i * 0.05}s` }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{slot.title}</h3>
|
||||
<p className="text-sm text-gray-500">{slot.date} | {slot.start_time} - {slot.end_time}</p>
|
||||
<div
|
||||
className={`
|
||||
bg-white/[0.03] border rounded-xl p-4 transition-all duration-200 cursor-pointer
|
||||
${selectedSlot === slot.id
|
||||
? 'border-amber-500/30 bg-amber-500/5'
|
||||
: slot.available
|
||||
? 'border-white/[0.06] hover:border-white/[0.12]'
|
||||
: 'border-white/[0.04] opacity-40 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
onClick={() => slot.available && setSelectedSlot(slot.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white/90">{slot.title}</h3>
|
||||
<p className="text-xs font-mono text-white/40 mt-1">
|
||||
{slot.date} · {slot.start_time} – {slot.end_time}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs font-mono px-2.5 py-1 rounded-md border ${
|
||||
slot.available
|
||||
? 'text-green-400 border-green-500/20 bg-green-500/5'
|
||||
: 'text-red-400 border-red-500/20 bg-red-500/5'
|
||||
}`}>
|
||||
{slot.available ? `${slot.max_bookings - slot.total_booked} frei` : 'Ausgebucht'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${slot.available ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{slot.available ? `${slot.max_bookings - slot.total_booked} frei` : 'Ausgebucht'}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSlot && (
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-4">Ihre Angaben</h3>
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-6 animate-fade-in">
|
||||
<h3 className="text-sm font-medium text-white/90 mb-4">Ihre Angaben</h3>
|
||||
<form onSubmit={handleSubmit(data => bookingMutation.mutate(data))} className="flex flex-col gap-4">
|
||||
<Input label="Name *" {...register('customer_name')} error={errors.customer_name?.message} />
|
||||
<Input label="Email *" type="email" {...register('customer_email')} error={errors.customer_email?.message} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Name *" {...register('customer_name')} error={errors.customer_name?.message} />
|
||||
<Input label="Email *" type="email" {...register('customer_email')} error={errors.customer_email?.message} />
|
||||
</div>
|
||||
<Input label="Unternehmen / Krankenhaus" {...register('customer_company')} />
|
||||
<Input label="Standort" {...register('customer_location')} />
|
||||
<Button type="submit" disabled={bookingMutation.isPending}>
|
||||
{bookingMutation.error && (
|
||||
<p className="text-sm text-red-400/90 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{(bookingMutation.error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={bookingMutation.isPending} className="w-full mt-1">
|
||||
{bookingMutation.isPending ? 'Wird gebucht...' : 'Termin buchen'}
|
||||
</Button>
|
||||
{bookingMutation.error && <p className="text-sm text-red-600">{(bookingMutation.error as Error).message}</p>}
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,9 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', label: '📅 Slots', end: true },
|
||||
{ to: '/dashboard/bookings', label: '📋 Buchungen', end: false },
|
||||
{ to: '/dashboard/settings', label: '⚙ Einstellungen', end: false },
|
||||
{ to: '/dashboard', label: 'Slots', end: true },
|
||||
{ to: '/dashboard/bookings', label: 'Buchungen', end: false },
|
||||
{ to: '/dashboard/settings', label: 'Einstellungen', end: false },
|
||||
];
|
||||
|
||||
export function DashboardLayout() {
|
||||
@@ -16,29 +16,49 @@ export function DashboardLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<aside className="w-64 bg-white border-r border-gray-200 p-4 flex flex-col">
|
||||
<h1 className="text-xl font-bold mb-6">UpdatePlaner</h1>
|
||||
<div className="min-h-screen flex" style={{ background: 'var(--bg-primary)' }}>
|
||||
<aside className="w-64 flex-shrink-0 border-r border-white/[0.06] backdrop-blur-xl p-5 flex flex-col" style={{ background: 'rgba(15, 17, 23, 0.85)' }}>
|
||||
<div className="flex items-center gap-2.5 mb-8 px-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white/90">UpdatePlaner</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-1 flex-1">
|
||||
{navItems.map(item => (
|
||||
{navItems.map((item, i) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`px-3 py-2 rounded-lg text-sm transition-colors ${isActive ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-100'}`
|
||||
`px-3 py-2 rounded-lg text-sm transition-all duration-200 animate-slide-in ${
|
||||
isActive
|
||||
? 'bg-amber-500/10 text-amber-400 font-medium border border-amber-500/10'
|
||||
: 'text-white/50 hover:text-white/80 hover:bg-white/[0.04] border border-transparent'
|
||||
}`
|
||||
}
|
||||
style={{ animationDelay: `${i * 0.06}s` }}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<button onClick={handleLogout} className="text-sm text-gray-500 hover:text-red-600 text-left px-3 py-2 cursor-pointer">
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-auto px-3 py-2 rounded-lg text-sm text-white/40 hover:text-red-400 transition-colors text-left hover:bg-red-500/5 border border-transparent cursor-pointer"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</aside>
|
||||
<main className="flex-1 p-8 overflow-auto">
|
||||
<Outlet />
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="max-w-5xl mx-auto p-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
@@ -24,19 +23,35 @@ export function SettingsPage() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['settings'] }),
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-gray-500">Lädt...</p>;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-6">Einstellungen</h2>
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit(data => mutation.mutate(data))} className="flex flex-col gap-4 max-w-md">
|
||||
<Input label="Standard max. Buchungen pro Slot" type="number" {...register('default_max_bookings')} />
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Speichert...' : 'Speichern'}</Button>
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-semibold text-white/90">Einstellungen</h1>
|
||||
<p className="text-sm text-white/40 mt-0.5">Globale Konfiguration</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-6 max-w-md">
|
||||
<form onSubmit={handleSubmit(data => mutation.mutate(data))} className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Standard max. Buchungen pro Slot"
|
||||
type="number"
|
||||
{...register('default_max_bookings')}
|
||||
/>
|
||||
<div className="flex justify-end pt-2 border-t border-white/[0.06]">
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Speichert...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,16 +44,18 @@ export function SlotForm({ slot, onClose }: SlotFormProps) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<Input label="Titel" {...register('title')} error={errors.title?.message} />
|
||||
<Input label="Titel" {...register('title')} error={errors.title?.message} placeholder="z.B. Windows 11 Update" />
|
||||
<Input label="Datum" type="date" {...register('date')} error={errors.date?.message} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Startzeit" type="time" {...register('start_time')} error={errors.start_time?.message} />
|
||||
<Input label="Endzeit" type="time" {...register('end_time')} error={errors.end_time?.message} />
|
||||
</div>
|
||||
<Input label="Max. gleichzeitige Buchungen" type="number" {...register('max_bookings')} error={errors.max_bookings?.message} />
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-white/[0.06]">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>{isSubmitting ? 'Speichert...' : slot ? 'Speichern' : 'Erstellen'}</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Speichert...' : slot ? 'Speichern' : 'Erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { SlotForm } from './SlotForm';
|
||||
import type { Slot } from '@/types';
|
||||
@@ -33,34 +32,69 @@ export function SlotsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold">Slots</h2>
|
||||
<Button onClick={openCreate}>Slot erstellen</Button>
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white/90">Slots</h1>
|
||||
<p className="text-sm text-white/40 mt-0.5">Update-Termin Slots verwalten</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>+ Slot erstellen</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-gray-500">Lädt...</p>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{slots?.map((slot: any) => (
|
||||
<Card key={slot.id} className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{slot.title}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{slot.date} | {slot.start_time} - {slot.end_time}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{slot.total_booked}/{slot.max_bookings} belegt
|
||||
</p>
|
||||
<div className="grid gap-3">
|
||||
{slots?.map((slot: any, i: number) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className="animate-fade-in"
|
||||
style={{ animationDelay: `${i * 0.04}s` }}
|
||||
>
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-5 hover:border-white/[0.1] transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-500/5 border border-amber-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-amber-400/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white/90">{slot.title}</h3>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs font-mono text-white/40">
|
||||
{slot.date}
|
||||
</span>
|
||||
<span className="text-white/20">·</span>
|
||||
<span className="text-xs font-mono text-white/40">
|
||||
{slot.start_time} – {slot.end_time}
|
||||
</span>
|
||||
<span className="text-white/20">·</span>
|
||||
<span className={`text-xs font-mono ${slot.total_booked >= slot.max_bookings ? 'text-red-400' : 'text-white/50'}`}>
|
||||
{slot.total_booked}/{slot.max_bookings} belegt
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => openEdit(slot)}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => deleteMutation.mutate(slot.id)}>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => openEdit(slot)}>Bearbeiten</Button>
|
||||
<Button variant="danger" onClick={() => deleteMutation.mutate(slot.id)}>Löschen</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
{slots?.length === 0 && <p className="text-gray-400 text-center py-8">Keine Slots vorhanden</p>}
|
||||
{(!slots || slots.length === 0) && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-sm text-white/30">Keine Slots vorhanden</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+117
@@ -1 +1,118 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'DM Sans', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
--color-amber-50: #fffbeb;
|
||||
--color-amber-100: #fef3c7;
|
||||
--color-amber-200: #fde68a;
|
||||
--color-amber-300: #fcd34d;
|
||||
--color-amber-400: #fbbf24;
|
||||
--color-amber-500: #f59e0b;
|
||||
--color-amber-600: #d97706;
|
||||
--color-amber-700: #b45309;
|
||||
|
||||
--color-slate-800: #1e293b;
|
||||
--color-slate-900: #0f172a;
|
||||
--color-slate-950: #020617;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0f1117;
|
||||
--bg-surface: #1a1d27;
|
||||
--bg-surface-hover: #222639;
|
||||
--bg-elevated: #232740;
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--border-medium: rgba(255, 255, 255, 0.1);
|
||||
--text-primary: rgba(255, 255, 255, 0.92);
|
||||
--text-secondary: rgba(255, 255, 255, 0.55);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.35);
|
||||
--amber: #f59e0b;
|
||||
--amber-dim: rgba(245, 158, 11, 0.15);
|
||||
--amber-glow: 0 0 20px rgba(245, 158, 11, 0.15);
|
||||
--green: #22c55e;
|
||||
--green-dim: rgba(34, 197, 94, 0.15);
|
||||
--red: #ef4444;
|
||||
--red-dim: rgba(239, 68, 68, 0.15);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.1) transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
[class*="font-mono"] {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from { opacity: 0; transform: translateX(-12px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(245, 158, 11, 0.1); }
|
||||
50% { box-shadow: 0 0 20px rgba(245, 158, 11, 0.25); }
|
||||
}
|
||||
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.stagger-1 { animation-delay: 0.05s; }
|
||||
.stagger-2 { animation-delay: 0.1s; }
|
||||
.stagger-3 { animation-delay: 0.15s; }
|
||||
.stagger-4 { animation-delay: 0.2s; }
|
||||
.stagger-5 { animation-delay: 0.25s; }
|
||||
.stagger-6 { animation-delay: 0.3s; }
|
||||
.stagger-7 { animation-delay: 0.35s; }
|
||||
.stagger-8 { animation-delay: 0.4s; }
|
||||
|
||||
.bg-noise {
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
|
||||
background-repeat: repeat;
|
||||
background-size: 256px 256px;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
input[type="time"]::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.7);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: var(--text-primary);
|
||||
-webkit-box-shadow: 0 0 0px 1000px var(--bg-surface) inset;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user