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:
61
apps/backoffice/app/components/ui/Badge.tsx
Normal file
61
apps/backoffice/app/components/ui/Badge.tsx
Normal 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>;
|
||||
}
|
||||
48
apps/backoffice/app/components/ui/Button.tsx
Normal file
48
apps/backoffice/app/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/backoffice/app/components/ui/Card.tsx
Normal file
27
apps/backoffice/app/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/backoffice/app/components/ui/Form.tsx
Normal file
57
apps/backoffice/app/components/ui/Form.tsx
Normal 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>;
|
||||
}
|
||||
94
apps/backoffice/app/components/ui/Icon.tsx
Normal file
94
apps/backoffice/app/components/ui/Icon.tsx
Normal 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>;
|
||||
}
|
||||
30
apps/backoffice/app/components/ui/Input.tsx
Normal file
30
apps/backoffice/app/components/ui/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
apps/backoffice/app/components/ui/ProgressBar.tsx
Normal file
56
apps/backoffice/app/components/ui/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/backoffice/app/components/ui/StatBox.tsx
Normal file
33
apps/backoffice/app/components/ui/StatBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/backoffice/app/components/ui/index.ts
Normal file
8
apps/backoffice/app/components/ui/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user