feat: Integrate shadcn/ui design system

- Install shadcn/ui dependencies (CVA, clsx, tailwind-merge, lucide-react)
- Install Radix UI primitives (slot, label)
- Create utility helper (lib/utils.ts with cn function)
- Update Tailwind config for shadcn/ui theme support
- Add CSS variables for theming in globals.css (Tailwind v4 syntax)
- Add shadcn/ui components: Button, Card, Input, Label
- Update JourneyForm with shadcn/ui components and icons
- Update DataPreview with shadcn/ui components and icons
- Improve UI consistency and accessibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2025-10-10 20:41:06 +02:00
parent 414d3c64e8
commit 410d5092ff
12 changed files with 530 additions and 141 deletions

View File

@@ -2,6 +2,9 @@
import { useState } from 'react'
import API_URL from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Upload, BarChart3, Loader2, FileText, CheckCircle2, AlertCircle } from 'lucide-react'
interface DataPreviewProps {
data: any
@@ -53,63 +56,54 @@ export default function DataPreview({ data, loading, formData }: DataPreviewProp
}
if (loading) {
return (
<div className="bg-white/98 backdrop-blur-lg rounded-3xl shadow-2xl border border-white/30 overflow-hidden">
<div className="flex flex-col items-center justify-center h-96 p-8">
<div className="relative">
<div className="animate-spin rounded-full h-20 w-20 border-4 border-blue-200"></div>
<div className="animate-spin rounded-full h-20 w-20 border-t-4 border-blue-600 absolute top-0"></div>
</div>
<Card className="bg-white/98 backdrop-blur-lg border-white/30">
<CardContent className="flex flex-col items-center justify-center h-96 p-8">
<Loader2 className="h-20 w-20 text-blue-600 animate-spin" />
<p className="mt-6 text-gray-700 font-semibold text-lg">Načítání dat...</p>
</div>
</div>
</CardContent>
</Card>
)
}
if (!data) {
return (
<div className="bg-white/98 backdrop-blur-lg rounded-3xl shadow-2xl border border-white/30 overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-purple-100 px-8 py-6 border-b border-purple-200">
<div className="flex items-center gap-4">
<Card className="bg-white/98 backdrop-blur-lg border-white/30">
<CardHeader className="bg-gradient-to-r from-purple-50 to-purple-100 border-b border-purple-200">
<CardTitle className="text-3xl font-bold text-gray-800 flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<BarChart3 className="w-7 h-7 text-white" />
</div>
<h2 className="text-3xl font-bold text-gray-800">Náhled dat</h2>
<span>Náhled dat</span>
</CardTitle>
</CardHeader>
<CardContent className="p-8">
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<FileText className="w-10 h-10 text-gray-400" />
</div>
<p className="text-gray-500 font-medium">
Vyplňte formulář a klikněte na "Vypočítat"
</p>
<p className="text-gray-400 text-sm mt-1">
Data se zobrazí zde
</p>
</div>
</div>
<div className="p-8">
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<p className="text-gray-500 font-medium">
Vyplňte formulář a klikněte na "Vypočítat"
</p>
<p className="text-gray-400 text-sm mt-1">
Data se zobrazí zde
</p>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
return (
<div className="bg-white/98 backdrop-blur-lg rounded-3xl shadow-2xl border border-white/30 overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-purple-100 px-8 py-6 border-b border-purple-200">
<div className="flex items-center gap-4">
<Card className="bg-white/98 backdrop-blur-lg border-white/30">
<CardHeader className="bg-gradient-to-r from-purple-50 to-purple-100 border-b border-purple-200">
<CardTitle className="text-3xl font-bold text-gray-800 flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<BarChart3 className="w-7 h-7 text-white" />
</div>
<h2 className="text-3xl font-bold text-gray-800">Náhled dat</h2>
</div>
</div>
<div className="p-8">
<span>Náhled dat</span>
</CardTitle>
</CardHeader>
<CardContent className="p-8">
<div className="mb-6 grid grid-cols-2 gap-3 bg-gradient-to-br from-blue-50 to-indigo-50 p-5 rounded-xl border border-blue-100">
<div>
@@ -165,23 +159,39 @@ export default function DataPreview({ data, loading, formData }: DataPreviewProp
{formData && formData.startDate === '2025-01-01' && (
<div className="mt-6">
<button
<Button
onClick={handleFillToWebsite}
disabled={filling}
className="w-full bg-gradient-to-r from-orange-600 to-orange-700 hover:from-orange-700 hover:to-orange-800 text-white font-bold py-4 px-6 rounded-xl shadow-lg hover:shadow-2xl transform hover:-translate-y-1 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
size="lg"
className="w-full bg-gradient-to-r from-orange-600 to-orange-700 hover:from-orange-700 hover:to-orange-800 text-lg h-12"
>
<span className="flex items-center justify-center gap-3">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span className="text-lg">{filling ? 'Vyplňování...' : 'Vyplnit na web'}</span>
</span>
</button>
{filling ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Vyplňování...
</>
) : (
<>
<Upload className="mr-2 h-5 w-5" />
Vyplnit na web
</>
)}
</Button>
{fillResult && (
<div className={`mt-4 ${fillResult.dry_run ? 'bg-blue-50 border-blue-200' : 'bg-green-50 border-green-200'} border rounded-xl p-4`}>
<h3 className={`font-bold ${fillResult.dry_run ? 'text-blue-900' : 'text-green-900'} mb-2`}>
{fillResult.dry_run ? 'Výsledek DRY RUN:' : 'Výsledek vyplňování:'}
<h3 className={`font-bold ${fillResult.dry_run ? 'text-blue-900' : 'text-green-900'} mb-2 flex items-center gap-2`}>
{fillResult.dry_run ? (
<>
<AlertCircle className="h-5 w-5" />
Výsledek DRY RUN:
</>
) : (
<>
<CheckCircle2 className="h-5 w-5" />
Výsledek vyplňování:
</>
)}
</h3>
<div className={`text-sm ${fillResult.dry_run ? 'text-blue-800' : 'text-green-800'}`}>
<p>Měsíc: {fillResult.month}</p>
@@ -205,18 +215,23 @@ export default function DataPreview({ data, loading, formData }: DataPreviewProp
</>
)}
{fillResult.dry_run && (
<p className="mt-2 font-semibold text-orange-700">
DRY RUN MODE - Data nebyla odeslána na web
<p className="mt-2 font-semibold text-orange-700 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
DRY RUN MODE - Data nebyla odeslána na web
</p>
)}
{!fillResult.dry_run && fillResult.updates_successful > 0 && (
<p className="mt-2 font-semibold text-green-700">
Data byla úspěšně vyplněna. Nyní zkontrolujte na webu a klikněte "Uzavřít měsíc" ručně.
<p className="mt-2 font-semibold text-green-700 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
Data byla úspěšně vyplněna. Nyní zkontrolujte na webu a klikněte "Uzavřít měsíc" ručně.
</p>
)}
{!fillResult.dry_run && fillResult.errors && fillResult.errors.length > 0 && (
<div className="mt-2 p-2 bg-red-100 border border-red-300 rounded">
<p className="font-semibold text-red-900">Chyby:</p>
<p className="font-semibold text-red-900 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Chyby:
</p>
{fillResult.errors.map((err: any, idx: number) => (
<p key={idx} className="text-xs text-red-800"> {err.type} řádek {err.row}: {err.error}</p>
))}
@@ -227,7 +242,7 @@ export default function DataPreview({ data, loading, formData }: DataPreviewProp
)}
</div>
)}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -2,6 +2,11 @@
import { useState } from 'react'
import API_URL from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Calculator, Download } from 'lucide-react'
interface JourneyFormProps {
onDataCalculated: (data: any) => void
@@ -99,130 +104,114 @@ export default function JourneyForm({ onDataCalculated, setLoading, onFormDataCh
}
return (
<div className="bg-white/98 backdrop-blur-lg rounded-3xl shadow-2xl overflow-hidden border border-white/30">
<div className="bg-gradient-to-r from-blue-50 to-blue-100 px-8 py-6 border-b border-blue-200">
<div className="flex items-center gap-4">
<Card className="bg-white/98 backdrop-blur-lg border-white/30">
<CardHeader className="bg-gradient-to-r from-blue-50 to-blue-100 border-b border-blue-200">
<CardTitle className="text-3xl font-bold text-gray-800 flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<h2 className="text-3xl font-bold text-gray-800">Vstupní údaje</h2>
</div>
</div>
<div className="p-8">
<span>Vstupní údaje</span>
</CardTitle>
</CardHeader>
<CardContent className="p-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Uživatelské jméno
</label>
<input
<div className="space-y-2">
<Label htmlFor="username">Uživatelské jméno</Label>
<Input
id="username"
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="Zadejte uživatelské jméno"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Heslo
</label>
<input
<div className="space-y-2">
<Label htmlFor="password">Heslo</Label>
<Input
id="password"
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="Zadejte heslo"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datum od
</label>
<input
<div className="space-y-2">
<Label htmlFor="startDate">Datum od</Label>
<Input
id="startDate"
type="date"
required
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datum do
</label>
<input
<div className="space-y-2">
<Label htmlFor="endDate">Datum do</Label>
<Input
id="endDate"
type="date"
required
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Počáteční stav [km]
</label>
<input
<div className="space-y-2">
<Label htmlFor="startKm">Počáteční stav [km]</Label>
<Input
id="startKm"
type="number"
required
value={formData.startKm}
onChange={(e) => setFormData({ ...formData, startKm: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Koncový stav [km]
</label>
<input
<div className="space-y-2">
<Label htmlFor="endKm">Koncový stav [km]</Label>
<Input
id="endKm"
type="number"
required
value={formData.endKm}
onChange={(e) => setFormData({ ...formData, endKm: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
SPZ vozidla
</label>
<input
<div className="space-y-2">
<Label htmlFor="vehicleRegistration">SPZ vozidla</Label>
<Input
id="vehicleRegistration"
type="text"
value={formData.vehicleRegistration}
onChange={(e) => setFormData({ ...formData, vehicleRegistration: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Variance (0-1)
</label>
<input
<div className="space-y-2">
<Label htmlFor="variance">Variance (0-1)</Label>
<Input
id="variance"
type="number"
step="0.01"
min="0"
max="1"
value={formData.variance}
onChange={(e) => setFormData({ ...formData, variance: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-muted-foreground">
Náhodná variace rozdělení kilometrů (doporučeno 0.1 = 10%)
</p>
</div>
@@ -237,33 +226,27 @@ export default function JourneyForm({ onDataCalculated, setLoading, onFormDataCh
)}
<div className="flex flex-col sm:flex-row gap-4 pt-6">
<button
<Button
type="submit"
className="flex-1 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-bold py-4 px-6 rounded-xl shadow-lg hover:shadow-2xl transform hover:-translate-y-1 transition-all duration-200"
size="lg"
className="flex-1 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-lg h-12"
>
<span className="flex items-center justify-center gap-3">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="text-lg">Vypočítat</span>
</span>
</button>
<Calculator className="mr-2 h-5 w-5" />
Vypočítat
</Button>
<button
<Button
type="button"
onClick={handleExport}
className="flex-1 bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white font-bold py-4 px-6 rounded-xl shadow-lg hover:shadow-2xl transform hover:-translate-y-1 transition-all duration-200"
size="lg"
className="flex-1 bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-lg h-12"
>
<span className="flex items-center justify-center gap-3">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-lg">Export Excel</span>
</span>
</button>
<Download className="mr-2 h-5 w-5" />
Export Excel
</Button>
</div>
</form>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,9 +1,26 @@
@import "tailwindcss";
@theme {
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--radius-card: 12px;
--color-background: 0 0% 100%;
--color-foreground: 222.2 84% 4.9%;
--color-card: 0 0% 100%;
--color-card-foreground: 222.2 84% 4.9%;
--color-popover: 0 0% 100%;
--color-popover-foreground: 222.2 84% 4.9%;
--color-primary: 221.2 83.2% 53.3%;
--color-primary-foreground: 210 40% 98%;
--color-secondary: 210 40% 96.1%;
--color-secondary-foreground: 222.2 47.4% 11.2%;
--color-muted: 210 40% 96.1%;
--color-muted-foreground: 215.4 16.3% 46.9%;
--color-accent: 210 40% 96.1%;
--color-accent-foreground: 222.2 47.4% 11.2%;
--color-destructive: 0 84.2% 60.2%;
--color-destructive-foreground: 210 40% 98%;
--color-border: 214.3 31.8% 91.4%;
--color-input: 214.3 31.8% 91.4%;
--color-ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
* {
@@ -16,5 +33,5 @@ body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #1f2937;
color: hsl(var(--color-foreground));
}

17
frontend/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

6
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -9,14 +9,20 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/postcss": "^4.1.14",
"@types/node": "^24.7.1",
"@types/react": "^19.2.2",
"autoprefixer": "^10.4.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"next": "^15.5.4",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3"
}
@@ -662,6 +668,85 @@
"node": ">= 10"
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1058,12 +1143,33 @@
"node": ">=18"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -1363,6 +1469,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide-react": {
"version": "0.545.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz",
"integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
@@ -1661,6 +1776,16 @@
}
}
},
"node_modules/tailwind-merge": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",

View File

@@ -13,14 +13,20 @@
"license": "ISC",
"description": "",
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/postcss": "^4.1.14",
"@types/node": "^24.7.1",
"@types/react": "^19.2.2",
"autoprefixer": "^10.4.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"next": "^15.5.4",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3"
}

View File

@@ -6,7 +6,48 @@ module.exports = {
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
extend: {
colors: {
border: "hsl(var(--color-border))",
input: "hsl(var(--color-input))",
ring: "hsl(var(--color-ring))",
background: "hsl(var(--color-background))",
foreground: "hsl(var(--color-foreground))",
primary: {
DEFAULT: "hsl(var(--color-primary))",
foreground: "hsl(var(--color-primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--color-secondary))",
foreground: "hsl(var(--color-secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--color-destructive))",
foreground: "hsl(var(--color-destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--color-muted))",
foreground: "hsl(var(--color-muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--color-accent))",
foreground: "hsl(var(--color-accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--color-popover))",
foreground: "hsl(var(--color-popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--color-card))",
foreground: "hsl(var(--color-card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
}