feat(ui): Primitives & Design Tokens fuer Cinematic Cinema OS

This commit is contained in:
Hitonabi
2026-07-24 16:04:41 +02:00
parent 7b6f836475
commit 7004d798d6
8 changed files with 403 additions and 49 deletions
+39
View File
@@ -0,0 +1,39 @@
import { Disc } from 'lucide-react'
import { STATUS_STYLES, DISC_TYPE_STYLES } from '../../lib/design'
interface StatusBadgeProps {
status: string
className?: string
}
export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
const config = STATUS_STYLES[status as keyof typeof STATUS_STYLES] || {
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
label: status,
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${config.badge} ${className}`}>
{config.label}
</span>
)
}
interface TypeBadgeProps {
type: string
className?: string
}
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
label: type.toUpperCase(),
}
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border ${config.badge} ${className}`}>
<Disc size={13} />
{config.label}
</span>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'amber'
size?: 'sm' | 'md' | 'lg'
children: ReactNode
className?: string
}
export function Button({
variant = 'primary',
size = 'md',
children,
className = '',
disabled,
...props
}: ButtonProps) {
const base = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]'
const variants = {
primary: 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 dark:shadow-indigo-900/40 focus:ring-indigo-500',
amber: 'bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-semibold shadow-lg shadow-amber-500/20 focus:ring-amber-500',
secondary: 'bg-slate-100 dark:bg-slate-800/80 hover:bg-slate-200 dark:hover:bg-slate-700/80 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700/80 focus:ring-slate-400',
ghost: 'bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800/60 text-slate-600 dark:text-slate-300 focus:ring-slate-400',
danger: 'bg-rose-600/90 hover:bg-rose-500 text-white shadow-lg shadow-rose-500/20 focus:ring-rose-500',
}
const sizes = {
sm: 'px-2.5 py-1.5 text-xs gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-5 py-2.5 text-base gap-2.5',
}
return (
<button
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled}
{...props}
>
{children}
</button>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { ReactNode } from 'react'
interface CardProps {
children: ReactNode
className?: string
glow?: boolean
onClick?: () => void
}
export function Card({ children, className = '', glow = false, onClick }: CardProps) {
return (
<div
onClick={onClick}
className={`rounded-2xl border transition-all duration-300 ${
glow ? 'shadow-[0_0_30px_-5px_rgba(245,158,11,0.15)] border-amber-500/30' : 'shadow-lg shadow-black/5 border-slate-200/80 dark:border-slate-800/80'
} bg-white/90 dark:bg-slate-900/80 backdrop-blur-md text-slate-900 dark:text-slate-100 ${
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-xl' : ''
} ${className}`}
>
{children}
</div>
)
}
export function CardHeader({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<div className={`px-6 py-4 border-b border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between ${className}`}>
{children}
</div>
)
}
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<h2 className={`text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
{children}
</h2>
)
}
export function CardContent({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<div className={`p-6 ${className}`}>
{children}
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
className?: string
}
export function Input({ label, error, className = '', ...props }: InputProps) {
return (
<div className="space-y-1.5 w-full">
{label && (
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
{label}
</label>
)}
<input
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 placeholder:text-slate-400 dark:placeholder:text-slate-600 ${
error ? 'border-rose-500 ring-rose-500/20' : ''
} ${className}`}
{...props}
/>
{error && <p className="text-xs text-rose-500">{error}</p>}
</div>
)
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string
children: ReactNode
className?: string
}
export function Select({ label, children, className = '', ...props }: SelectProps) {
return (
<div className="space-y-1.5 w-full">
{label && (
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
{label}
</label>
)}
<select
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 ${className}`}
{...props}
>
{children}
</select>
</div>
)
}
interface ToggleProps {
checked: boolean
onChange: (checked: boolean) => void
label?: string
description?: string
disabled?: boolean
}
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
return (
<div className="flex items-center justify-between py-2">
{(label || description) && (
<div className="space-y-0.5">
{label && <span className="text-sm font-medium text-slate-900 dark:text-slate-100">{label}</span>}
{description && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>}
</div>
)}
<button
type="button"
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 ${
checked ? 'bg-amber-500' : 'bg-slate-200 dark:bg-slate-700'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { ReactNode, useEffect } from 'react'
import { X } from 'lucide-react'
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: string
children: ReactNode
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl'
}
export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: ModalProps) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
}
return () => {
document.body.style.overflow = 'unset'
window.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onClose])
if (!isOpen) return null
const maxWidening = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'4xl': 'max-w-4xl',
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
{/* Glass Backdrop */}
<div
className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity duration-300 animate-in fade-in"
onClick={onClose}
/>
{/* Modal Dialog */}
<div className={`relative w-full ${maxWidening[maxWidth]} bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl shadow-black/40 overflow-hidden z-10 animate-in zoom-in-95 duration-200`}>
{title && (
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<X size={18} />
</button>
</div>
)}
<div className="p-6">
{children}
</div>
</div>
</div>
)
}
@@ -0,0 +1,19 @@
import { ReactNode } from 'react'
interface PageHeaderProps {
title: string
subtitle?: string
action?: ReactNode
}
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>}
</div>
{action && <div>{action}</div>}
</div>
)
}