feat(ui): Components refactoring with Tailwind - UI kit, icons, lazy loading images

- Created reusable UI components (Card, Button, Badge, Form, Icon)
- Added PageIcon and NavIcon components with consistent styling
- Refactored all pages to use new UI components
- Added non-blocking image loading with skeleton for book covers
- Created LibraryActions dropdown for library settings
- Added emojis to buttons for better UX
- Fixed Client Component issues with getBookCoverUrl
This commit is contained in:
2026-03-06 14:11:23 +01:00
parent 05a18c3c77
commit d001e29bbc
24 changed files with 1235 additions and 459 deletions

View File

@@ -0,0 +1,61 @@
type BadgeVariant = "default" | "primary" | "success" | "warning" | "error" | "muted";
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
}
const variantStyles: Record<BadgeVariant, string> = {
default: "bg-muted/20 text-muted",
primary: "bg-primary-soft text-primary",
success: "bg-success-soft text-success",
warning: "bg-warning-soft text-warning",
error: "bg-error-soft text-error",
muted: "bg-muted/10 text-muted",
};
export function Badge({ children, variant = "default", className = "" }: BadgeProps) {
return (
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${variantStyles[variant]} ${className}`}>
{children}
</span>
);
}
type StatusVariant = "running" | "success" | "failed" | "cancelled" | "pending";
interface StatusBadgeProps {
status: string;
className?: string;
}
const statusVariants: Record<StatusVariant, BadgeVariant> = {
running: "primary",
success: "success",
failed: "error",
cancelled: "muted",
pending: "warning",
};
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
const variant = statusVariants[status as StatusVariant] || "default";
return <Badge variant={variant} className={className}>{status}</Badge>;
}
type JobTypeVariant = "rebuild" | "full_rebuild";
interface JobTypeBadgeProps {
type: string;
className?: string;
}
const jobTypeVariants: Record<JobTypeVariant, BadgeVariant> = {
rebuild: "primary",
full_rebuild: "warning",
};
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
const variant = jobTypeVariants[type as JobTypeVariant] || "default";
return <Badge variant={variant} className={className}>{type}</Badge>;
}

View File

@@ -0,0 +1,48 @@
import { ButtonHTMLAttributes, ReactNode } from "react";
type ButtonVariant = "primary" | "secondary" | "danger" | "warning" | "ghost";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: ButtonVariant;
size?: "sm" | "md" | "lg";
}
const variantStyles: Record<ButtonVariant, string> = {
primary: "bg-primary text-white hover:bg-primary/90",
secondary: "border border-line text-muted hover:bg-muted/5",
danger: "bg-error text-white hover:bg-error/90",
warning: "bg-warning text-white hover:bg-warning/90",
ghost: "text-muted hover:text-foreground hover:bg-muted/5",
};
const sizeStyles: Record<string, string> = {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
};
export function Button({
children,
variant = "primary",
size = "md",
className = "",
disabled,
...props
}: ButtonProps) {
return (
<button
className={`
inline-flex items-center justify-center font-medium rounded-lg transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
disabled={disabled}
{...props}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,27 @@
import { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
}
export function Card({ children, className = "" }: CardProps) {
return (
<div className={`bg-card rounded-xl shadow-soft border border-line p-6 ${className}`}>
{children}
</div>
);
}
interface CardHeaderProps {
title: string;
className?: string;
}
export function CardHeader({ title, className = "" }: CardHeaderProps) {
return (
<h2 className={`text-lg font-semibold text-foreground mb-4 ${className}`}>
{title}
</h2>
);
}

View File

@@ -0,0 +1,57 @@
import { ReactNode, LabelHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes } from "react";
interface FormFieldProps {
children: ReactNode;
className?: string;
}
export function FormField({ children, className = "" }: FormFieldProps) {
return <div className={`flex-1 min-w-48 ${className}`}>{children}</div>;
}
interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
children: ReactNode;
}
export function FormLabel({ children, className = "", ...props }: FormLabelProps) {
return (
<label className={`block text-sm font-medium text-foreground mb-1.5 ${className}`} {...props}>
{children}
</label>
);
}
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {}
export function FormInput({ className = "", ...props }: FormInputProps) {
return (
<input
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
{...props}
/>
);
}
interface FormSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
children: ReactNode;
}
export function FormSelect({ children, className = "", ...props }: FormSelectProps) {
return (
<select
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
{...props}
>
{children}
</select>
);
}
interface FormRowProps {
children: ReactNode;
className?: string;
}
export function FormRow({ children, className = "" }: FormRowProps) {
return <div className={`flex items-end gap-3 flex-wrap ${className}`}>{children}</div>;
}

View File

@@ -0,0 +1,94 @@
type IconName = "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "series";
interface PageIconProps {
name: IconName;
className?: string;
}
const icons: Record<IconName, React.ReactNode> = {
dashboard: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
books: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
),
libraries: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
),
jobs: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
tokens: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
),
series: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
};
const colors: Record<IconName, string> = {
dashboard: "text-primary",
books: "text-success",
libraries: "text-primary",
jobs: "text-warning",
tokens: "text-error",
series: "text-primary",
};
export function PageIcon({ name, className = "" }: PageIconProps) {
return (
<span className={`${colors[name]} ${className}`}>
{icons[name]}
</span>
);
}
// Nav icons (smaller)
export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) {
const navIcons: Record<IconName, React.ReactNode> = {
dashboard: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
books: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
),
libraries: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
),
jobs: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
tokens: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
),
series: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
};
return <span className={className}>{navIcons[name]}</span>;
}

View File

@@ -0,0 +1,30 @@
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export function Input({ label, className = "", ...props }: InputProps) {
return (
<input
className={`px-4 py-2.5 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary ${className}`}
{...props}
/>
);
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
children: ReactNode;
}
export function Select({ label, children, className = "", ...props }: SelectProps) {
return (
<select
className={`px-4 py-2.5 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary ${className}`}
{...props}
>
{children}
</select>
);
}

View File

@@ -0,0 +1,56 @@
interface ProgressBarProps {
value: number;
max?: number;
showLabel?: boolean;
size?: "sm" | "md" | "lg";
className?: string;
}
const sizeStyles = {
sm: "h-1.5",
md: "h-2",
lg: "h-8",
};
export function ProgressBar({
value,
max = 100,
showLabel = false,
size = "md",
className = ""
}: ProgressBarProps) {
const percent = Math.min(100, Math.max(0, (value / max) * 100));
return (
<div className={`relative ${sizeStyles[size]} bg-line rounded-full overflow-hidden ${className}`}>
<div
className="absolute inset-y-0 left-0 bg-success rounded-full transition-all duration-300"
style={{ width: `${percent}%` }}
/>
{showLabel && (
<span className="absolute inset-0 flex items-center justify-center text-sm font-semibold text-foreground">
{Math.round(percent)}%
</span>
)}
</div>
);
}
interface MiniProgressBarProps {
value: number;
max?: number;
className?: string;
}
export function MiniProgressBar({ value, max = 100, className = "" }: MiniProgressBarProps) {
const percent = Math.min(100, Math.max(0, (value / max) * 100));
return (
<div className={`flex-1 h-1.5 bg-line rounded-full overflow-hidden ${className}`}>
<div
className="h-full bg-success rounded-full transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { ReactNode } from "react";
interface StatBoxProps {
value: ReactNode;
label: string;
variant?: "default" | "primary" | "success" | "warning" | "error";
className?: string;
}
const variantStyles: Record<string, string> = {
default: "bg-muted/5",
primary: "bg-primary-soft",
success: "bg-success-soft",
warning: "bg-warning-soft",
error: "bg-error-soft",
};
const valueVariantStyles: Record<string, string> = {
default: "text-foreground",
primary: "text-primary",
success: "text-success",
warning: "text-warning",
error: "text-error",
};
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
return (
<div className={`text-center p-4 rounded-lg ${variantStyles[variant]} ${className}`}>
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
<span className={`text-xs ${valueVariantStyles[variant]}/80`}>{label}</span>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export { Card, CardHeader } from "./Card";
export { Badge, StatusBadge, JobTypeBadge } from "./Badge";
export { StatBox } from "./StatBox";
export { ProgressBar, MiniProgressBar } from "./ProgressBar";
export { Button } from "./Button";
export { Input, Select } from "./Input";
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
export { PageIcon, NavIcon } from "./Icon";