feat(backoffice): redesign UI with enhanced background and glassmorphism effects

- Add vibrant radial gradient backgrounds with multiple color zones
- Implement glassmorphism effects on header and cards
- Add subtle grain texture overlay
- Update card hover effects with smooth transitions
- Improve dark mode background visibility
This commit is contained in:
2026-03-06 16:21:48 +01:00
parent 2b30ae47de
commit 7cdc72b6e1
30 changed files with 1783 additions and 694 deletions

View File

@@ -13,22 +13,20 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
const [isLoaded, setIsLoaded] = useState(false);
return (
<div className="relative aspect-[2/3] overflow-hidden bg-gradient-to-br from-line/50 to-line">
<div className="relative aspect-[2/3] overflow-hidden bg-muted">
{/* Skeleton */}
<div
className={`absolute inset-0 bg-muted/10 animate-pulse transition-opacity duration-300 ${
className={`absolute inset-0 bg-muted/50 animate-pulse transition-opacity duration-300 ${
isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
}`}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/20 to-transparent shimmer" />
</div>
/>
{/* Image */}
<Image
src={src}
alt={alt}
fill
className={`object-cover group-hover:scale-105 transition-all duration-300 ${
className={`object-cover group-hover:scale-105 transition-transform duration-300 ${
isLoaded ? 'opacity-100' : 'opacity-0'
}`}
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
@@ -45,7 +43,7 @@ export function BookCard({ book }: BookCardProps) {
return (
<Link
href={`/books/${book.id}`}
className="group block bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all duration-200 overflow-hidden"
className="group block bg-card rounded-xl border border-border/60 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-200 overflow-hidden"
>
<BookImage
src={coverUrl}
@@ -62,11 +60,11 @@ export function BookCard({ book }: BookCardProps) {
</h3>
{book.author && (
<p className="text-sm text-muted mb-1 truncate">{book.author}</p>
<p className="text-sm text-muted-foreground mb-1 truncate">{book.author}</p>
)}
{book.series && (
<p className="text-xs text-muted/80 truncate mb-2">
<p className="text-xs text-muted-foreground/80 truncate mb-2">
{book.series}
{book.volume && <span className="text-primary font-medium"> #{book.volume}</span>}
</p>
@@ -76,14 +74,14 @@ export function BookCard({ book }: BookCardProps) {
<div className="flex items-center gap-2 mt-2">
<span className={`
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
${book.kind === 'cbz' ? 'bg-success-soft text-success' : ''}
${book.kind === 'cbr' ? 'bg-warning-soft text-warning' : ''}
${book.kind === 'pdf' ? 'bg-error-soft text-error' : ''}
${book.kind === 'cbz' ? 'bg-success/10 text-success' : ''}
${book.kind === 'cbr' ? 'bg-warning/10 text-warning' : ''}
${book.kind === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
`}>
{book.kind}
</span>
{book.language && (
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary-soft text-primary">
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary/10 text-primary">
{book.language}
</span>
)}
@@ -114,12 +112,12 @@ interface EmptyStateProps {
export function EmptyState({ message }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 mb-4 text-muted/30">
<div className="w-16 h-16 mb-4 text-muted-foreground/30">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
</div>
<p className="text-muted text-lg">{message}</p>
<p className="text-muted-foreground text-lg">{message}</p>
</div>
);
}

View File

@@ -70,7 +70,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
if (error) {
return (
<div className="p-4 bg-error-soft text-error rounded-lg text-sm">
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
Error: {error}
</div>
);
@@ -78,7 +78,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
if (!progress) {
return (
<div className="p-4 text-muted text-sm">
<div className="p-4 text-muted-foreground text-sm">
Loading progress...
</div>
);
@@ -89,7 +89,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
const total = progress.total_files ?? 0;
return (
<div className="p-4 bg-card rounded-lg border border-line">
<div className="p-4 bg-card rounded-lg border border-border">
<div className="flex items-center justify-between mb-3">
<StatusBadge status={progress.status} />
{isComplete && (
@@ -99,7 +99,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
<ProgressBar value={percent} showLabel size="lg" className="mb-3" />
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted mb-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
<span>{processed} / {total} files</span>
{progress.current_file && (
<span className="truncate max-w-md" title={progress.current_file}>

View File

@@ -62,7 +62,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
return (
<>
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
<tr className={highlighted ? 'bg-primary/10' : 'hover:bg-muted/50'}>
<td className="px-4 py-3">
<Link
href={`/jobs/${job.id}`}
@@ -115,10 +115,10 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-muted">
<td className="px-4 py-3 text-sm text-muted-foreground">
{duration}
</td>
<td className="px-4 py-3 text-sm text-muted">
<td className="px-4 py-3 text-sm text-muted-foreground">
{formatDate(job.created_at)}
</td>
<td className="px-4 py-3">
@@ -143,7 +143,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
</tr>
{showProgress && (job.status === "running" || job.status === "pending") && (
<tr>
<td colSpan={8} className="px-4 py-3 bg-muted/5">
<td colSpan={8} className="px-4 py-3 bg-muted/50">
<JobProgress
jobId={job.id}
onComplete={handleComplete}

View File

@@ -2,6 +2,9 @@
import { useEffect, useState, useRef } from "react";
import Link from "next/link";
import { Button } from "./ui/Button";
import { Badge } from "./ui/Badge";
import { ProgressBar } from "./ui/ProgressBar";
interface Job {
id: string;
@@ -19,6 +22,27 @@ interface Job {
} | null;
}
// Icons
const JobsIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="2" y="3" width="20" height="18" rx="2" />
<path d="M6 8h12M6 12h12M6 16h8" strokeLinecap="round" />
</svg>
);
const SpinnerIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
);
const ChevronIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export function JobsIndicator() {
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
const [isOpen, setIsOpen] = useState(false);
@@ -67,13 +91,18 @@ export function JobsIndicator() {
return (
<Link
href="/jobs"
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted transition-all duration-200 hover:text-foreground hover:bg-primary-soft"
className="
flex items-center justify-center
w-9 h-9
rounded-md
text-muted-foreground
hover:text-foreground
hover:bg-accent
transition-colors duration-200
"
title="View all jobs"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="18" rx="2" />
<path d="M6 8h12M6 12h12M6 16h8" />
</svg>
<JobsIcon className="w-[18px] h-[18px]" />
</Link>
);
}
@@ -81,56 +110,61 @@ export function JobsIndicator() {
return (
<div className="relative" ref={dropdownRef}>
<button
className={`flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-all duration-200 ${
runningJobs.length > 0
? 'bg-success-soft text-success'
: 'bg-warning-soft text-warning'
} ${isOpen ? 'ring-2 ring-primary' : ''}`}
className={`
flex items-center gap-2
px-3 py-2
rounded-md
font-medium text-sm
transition-all duration-200
${runningJobs.length > 0
? 'bg-success/10 text-success hover:bg-success/20'
: 'bg-warning/10 text-warning hover:bg-warning/20'
}
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
`}
onClick={() => setIsOpen(!isOpen)}
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
>
{/* Animated spinner for running jobs */}
{runningJobs.length > 0 && (
<div className="w-4 h-4 animate-spin">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
<SpinnerIcon className="w-4 h-4" />
</div>
)}
{/* Icon */}
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="18" rx="2" />
<path d="M6 8h12M6 12h12M6 16h8" />
</svg>
<JobsIcon className="w-4 h-4" />
{/* Badge with count */}
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-bold text-white bg-current rounded-full">
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-bold bg-current rounded-full">
<span className="text-background">{totalCount > 99 ? "99+" : totalCount}</span>
</span>
{/* Chevron */}
<svg
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
<ChevronIcon
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Popin/Dropdown */}
{/* Popin/Dropdown with glassmorphism */}
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-96 bg-card rounded-xl shadow-card border border-line overflow-hidden z-50">
<div className="flex items-center justify-between px-4 py-3 border-b border-line bg-muted/5">
<div className="
absolute right-0 top-full mt-2 w-96
bg-popover/95 backdrop-blur-md
rounded-xl
shadow-elevation-2
border border-border/60
overflow-hidden
z-50
animate-scale-in
">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/60 bg-muted/50">
<div className="flex items-center gap-3">
<span className="text-2xl">📊</span>
<span className="text-xl">📊</span>
<div>
<h3 className="font-semibold text-foreground">Active Jobs</h3>
<p className="text-xs text-muted">
<p className="text-xs text-muted-foreground">
{runningJobs.length > 0
? `${runningJobs.length} running, ${pendingJobs.length} pending`
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
@@ -149,33 +183,29 @@ export function JobsIndicator() {
{/* Overall progress bar if running */}
{runningJobs.length > 0 && (
<div className="px-4 py-3 border-b border-line">
<div className="px-4 py-3 border-b border-border/60">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted">Overall Progress</span>
<span className="text-muted-foreground">Overall Progress</span>
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
</div>
<div className="h-2 bg-line rounded-full overflow-hidden">
<div
className="h-full bg-success rounded-full transition-all duration-500"
style={{ width: `${totalProgress}%` }}
/>
</div>
<ProgressBar value={totalProgress} size="sm" variant="success" />
</div>
)}
<div className="max-h-80 overflow-y-auto">
{/* Job List */}
<div className="max-h-80 overflow-y-auto scrollbar-hide">
{activeJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<span className="text-4xl mb-2"></span>
<p>No active jobs</p>
</div>
) : (
<ul className="divide-y divide-line">
<ul className="divide-y divide-border/60">
{activeJobs.map(job => (
<li key={job.id}>
<Link
href={`/jobs/${job.id}`}
className="block px-4 py-3 hover:bg-muted/5 transition-colors"
className="block px-4 py-3 hover:bg-accent/50 transition-colors duration-200"
onClick={() => setIsOpen(false)}
>
<div className="flex items-start gap-3">
@@ -186,37 +216,30 @@ export function JobsIndicator() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<code className="text-xs px-1.5 py-0.5 bg-line/50 rounded font-mono">{job.id.slice(0, 8)}</code>
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
job.type === 'rebuild' ? 'bg-primary-soft text-primary' : 'bg-muted/20 text-muted'
}`}>
<code className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono">{job.id.slice(0, 8)}</code>
<Badge variant={job.type === 'rebuild' ? 'primary' : 'secondary'} className="text-[10px]">
{job.type}
</span>
</Badge>
</div>
{job.status === "running" && job.progress_percent !== null && (
<div className="flex items-center gap-2 mt-2">
<div className="flex-1 h-1.5 bg-line rounded-full overflow-hidden">
<div
className="h-full bg-success rounded-full transition-all duration-300"
style={{ width: `${job.progress_percent}%` }}
/>
</div>
<span className="text-xs font-medium text-muted">{job.progress_percent}%</span>
<MiniProgressBar value={job.progress_percent} />
<span className="text-xs font-medium text-muted-foreground">{job.progress_percent}%</span>
</div>
)}
{job.current_file && (
<p className="text-xs text-muted mt-1.5 truncate" title={job.current_file}>
<p className="text-xs text-muted-foreground mt-1.5 truncate" title={job.current_file}>
📄 {job.current_file}
</p>
)}
{job.stats_json && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted">
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<span> {job.stats_json.indexed_files}</span>
{job.stats_json.errors > 0 && (
<span className="text-error"> {job.stats_json.errors}</span>
<span className="text-destructive"> {job.stats_json.errors}</span>
)}
</div>
)}
@@ -230,11 +253,23 @@ export function JobsIndicator() {
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-line bg-muted/5">
<p className="text-xs text-muted text-center">Auto-refreshing every 2s</p>
<div className="px-4 py-2 border-t border-border/60 bg-muted/50">
<p className="text-xs text-muted-foreground text-center">Auto-refreshing every 2s</p>
</div>
</div>
)}
</div>
);
}
// Mini progress bar for dropdown
function MiniProgressBar({ value }: { value: number }) {
return (
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-success rounded-full transition-all duration-300"
style={{ width: `${value}%` }}
/>
</div>
);
}

View File

@@ -2,7 +2,6 @@
import { useState, useEffect } from "react";
import { JobRow } from "./JobRow";
import { MiniProgressBar } from "./ui";
interface Job {
id: string;
@@ -45,18 +44,15 @@ function formatDate(dateStr: string): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
// Less than 1 hour: show relative
if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
return `${mins}m ago`;
}
// Less than 24 hours: show hours
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
}
// Otherwise: show date
return date.toLocaleDateString();
}
@@ -105,22 +101,22 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
};
return (
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden">
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-line bg-muted/5">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">ID</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Library</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Files</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Duration</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Created</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">ID</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Library</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Files</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Duration</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
<tbody className="divide-y divide-border/60">
{jobs.map((job) => (
<JobRow
key={job.id}

View File

@@ -1,8 +1,7 @@
"use client";
import { useState, useRef, useEffect, useTransition } from "react";
import Link from "next/link";
import { Button, Badge } from "../components/ui";
import { Button } from "../components/ui";
interface LibraryActionsProps {
libraryId: string;
@@ -70,13 +69,16 @@ export function LibraryActions({
variant="ghost"
size="sm"
onClick={() => setIsOpen(!isOpen)}
className={isOpen ? "bg-muted/10" : ""}
className={isOpen ? "bg-accent" : ""}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</Button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-card border border-line p-4 z-50">
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-md border border-border/60 p-4 z-50">
<form action={handleSubmit}>
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -86,7 +88,7 @@ export function LibraryActions({
name="monitor_enabled"
value="true"
defaultChecked={monitorEnabled}
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
/>
Auto Scan
</label>
@@ -99,7 +101,7 @@ export function LibraryActions({
name="watcher_enabled"
value="true"
defaultChecked={watcherEnabled}
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
/>
File Watcher
</label>
@@ -110,7 +112,7 @@ export function LibraryActions({
<select
name="scan_mode"
defaultValue={scanMode}
className="text-sm border border-line rounded-lg px-2 py-1 bg-background"
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
>
<option value="manual">Manual</option>
<option value="hourly">Hourly</option>

View File

@@ -0,0 +1,111 @@
import Link from "next/link";
import { Card, Badge } from "./ui";
interface LibrarySubPageHeaderProps {
library: {
id: string;
name: string;
root_path: string;
book_count: number;
enabled: boolean;
};
title: string;
icon: React.ReactNode;
iconColor?: string;
filterInfo?: {
label: string;
clearHref: string;
clearLabel: string;
};
}
export function LibrarySubPageHeader({
library,
title,
icon,
iconColor = "text-primary",
filterInfo
}: LibrarySubPageHeaderProps) {
return (
<div className="space-y-6">
{/* Header avec breadcrumb intégré */}
<div>
<div className="flex items-center gap-2 mb-2">
<Link
href="/libraries"
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Libraries
</Link>
<span className="text-muted-foreground">/</span>
<span className="text-sm text-foreground font-medium">{library.name}</span>
</div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<span className={iconColor}>{icon}</span>
{title}
</h1>
</div>
{/* Info Bar - Version améliorée */}
<Card className="bg-muted/30 border-border/40">
<div className="p-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
{/* Path */}
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-muted-foreground" 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>
<code className="text-xs font-mono text-muted-foreground bg-background px-2 py-1 rounded border border-border/60">
{library.root_path}
</code>
</div>
{/* Divider */}
<span className="hidden sm:block w-px h-4 bg-border" />
{/* Book count */}
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-muted-foreground" 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>
<span className="text-foreground">
<span className="font-semibold">{library.book_count}</span>
<span className="text-muted-foreground ml-1">book{library.book_count !== 1 ? 's' : ''}</span>
</span>
</div>
{/* Divider */}
<span className="hidden sm:block w-px h-4 bg-border" />
{/* Status */}
<Badge
variant={library.enabled ? "success" : "muted"}
className="text-xs"
>
{library.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</div>
</Card>
{/* Filter Info (optionnel) */}
{filterInfo && (
<div className="flex items-center justify-between py-2">
<p className="text-muted-foreground text-sm">
{filterInfo.label}
</p>
<Link
href={filterInfo.clearHref as `/libraries/${string}/books`}
className="text-sm text-primary hover:text-primary/80 font-medium"
>
{filterInfo.clearLabel}
</Link>
</div>
)}
</div>
);
}

View File

@@ -42,14 +42,14 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
isPending
? 'opacity-50 cursor-not-allowed'
: 'hover:border-primary'
} ${monitorEnabled ? 'bg-primary-soft border-primary text-primary' : 'bg-card border-line text-muted'}`}>
} ${monitorEnabled ? 'bg-primary/10 border-primary text-primary' : 'bg-card border-border text-muted-foreground'}`}>
<input
type="checkbox"
name="monitor_enabled"
value="true"
defaultChecked={monitorEnabled}
disabled={isPending}
className="w-3.5 h-3.5 rounded border-line text-primary focus:ring-primary"
className="w-3.5 h-3.5 rounded border-border text-primary focus:ring-primary"
/>
<span>Auto</span>
</label>
@@ -58,14 +58,14 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
isPending
? 'opacity-50 cursor-not-allowed'
: 'hover:border-primary'
} ${watcherEnabled ? 'bg-warning-soft border-warning text-warning' : 'bg-card border-line text-muted'}`}>
} ${watcherEnabled ? 'bg-warning/10 border-warning text-warning' : 'bg-card border-border text-muted-foreground'}`}>
<input
type="checkbox"
name="watcher_enabled"
value="true"
defaultChecked={watcherEnabled}
disabled={isPending}
className="w-3.5 h-3.5 rounded border-line text-warning focus:ring-warning"
className="w-3.5 h-3.5 rounded border-border text-warning focus:ring-warning"
/>
<span title="Real-time file watcher"></span>
</label>
@@ -74,7 +74,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
name="scan_mode"
defaultValue={scanMode}
disabled={isPending}
className="px-3 py-1.5 text-sm rounded-lg border border-line bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
>
<option value="manual">Manual</option>
<option value="hourly">Hourly</option>

View File

@@ -1,61 +1,113 @@
type BadgeVariant = "default" | "primary" | "success" | "warning" | "error" | "muted";
import { ReactNode } from "react";
type BadgeVariant =
| "default"
| "primary"
| "secondary"
| "destructive"
| "outline"
| "success"
| "warning"
| "error"
| "muted"
| "unread"
| "in-progress"
| "completed";
interface BadgeProps {
children: React.ReactNode;
children: 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",
// shadcn/ui compatible
default: "bg-primary/90 text-primary-foreground border-transparent hover:bg-primary/80 backdrop-blur-md",
secondary: "bg-secondary/80 text-secondary-foreground border-transparent hover:bg-secondary/60 backdrop-blur-md",
destructive: "bg-destructive/90 text-destructive-foreground border-transparent hover:bg-destructive/80 backdrop-blur-md",
outline: "text-foreground border-border bg-background/50",
// Legacy + Additional variants
primary: "bg-primary/90 text-primary-foreground backdrop-blur-md",
success: "bg-success/90 text-success-foreground backdrop-blur-md",
warning: "bg-warning/90 text-white backdrop-blur-md",
error: "bg-destructive/90 text-destructive-foreground backdrop-blur-md",
muted: "bg-muted/60 text-muted-foreground backdrop-blur-md",
// Status badges from StripStream
unread: "badge-unread backdrop-blur-md",
"in-progress": "badge-in-progress backdrop-blur-md",
completed: "badge-completed backdrop-blur-md",
};
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}`}>
<span className={`
inline-flex items-center
px-2.5 py-0.5
rounded-full
text-xs font-semibold
border
transition-colors duration-200
${variantStyles[variant]}
${className}
`}>
{children}
</span>
);
}
type StatusVariant = "running" | "success" | "failed" | "cancelled" | "pending";
// Status badge for jobs/tasks
const statusVariants: Record<string, BadgeVariant> = {
running: "in-progress",
success: "completed",
completed: "completed",
failed: "error",
cancelled: "muted",
pending: "warning",
unread: "unread",
};
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";
const variant = statusVariants[status.toLowerCase()] || "default";
return <Badge variant={variant} className={className}>{status}</Badge>;
}
type JobTypeVariant = "rebuild" | "full_rebuild";
// Job type badge
const jobTypeVariants: Record<string, BadgeVariant> = {
rebuild: "primary",
full_rebuild: "warning",
};
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";
const variant = jobTypeVariants[type.toLowerCase()] || "default";
return <Badge variant={variant} className={className}>{type}</Badge>;
}
// Progress badge (shows percentage)
interface ProgressBadgeProps {
progress: number;
className?: string;
}
export function ProgressBadge({ progress, className = "" }: ProgressBadgeProps) {
let variant: BadgeVariant = "unread";
if (progress === 100) variant = "completed";
else if (progress > 0) variant = "in-progress";
return (
<Badge variant={variant} className={className}>
{progress}%
</Badge>
);
}

View File

@@ -1,6 +1,15 @@
import { ButtonHTMLAttributes, ReactNode } from "react";
type ButtonVariant = "primary" | "secondary" | "danger" | "warning" | "ghost";
type ButtonVariant =
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link"
| "primary"
| "danger"
| "warning";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
@@ -9,22 +18,29 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
}
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",
// shadcn/ui compatible variants
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/85 shadow-sm",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
// Legacy variants (mapped to new ones for compatibility)
primary: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
danger: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
warning: "bg-warning text-white hover:bg-warning/90 shadow-sm",
};
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",
sm: "h-9 px-3 text-xs rounded-md",
md: "h-10 px-4 py-2 text-sm rounded-md",
lg: "h-11 px-8 text-base rounded-md",
};
export function Button({
children,
variant = "primary",
variant = "default",
size = "md",
className = "",
disabled,
@@ -33,8 +49,12 @@ export function Button({
return (
<button
className={`
inline-flex items-center justify-center font-medium rounded-lg transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
inline-flex items-center justify-center
font-medium
transition-all duration-200 ease-out
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
active:scale-[0.98]
${variantStyles[variant]}
${sizeStyles[size]}
${className}
@@ -46,3 +66,46 @@ export function Button({
</button>
);
}
// Icon Button variant
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
size?: "sm" | "md" | "lg";
variant?: ButtonVariant;
title?: string;
}
const iconSizeStyles: Record<string, string> = {
sm: "h-8 w-8",
md: "h-9 w-9",
lg: "h-10 w-10",
};
export function IconButton({
children,
size = "md",
variant = "ghost",
className = "",
title,
...props
}: IconButtonProps) {
return (
<button
title={title}
className={`
inline-flex items-center justify-center
rounded-md
transition-all duration-200 ease-out
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
hover:bg-accent hover:text-accent-foreground
active:scale-[0.96]
${iconSizeStyles[size]}
${className}
`}
{...props}
>
{children}
</button>
);
}

View File

@@ -3,25 +3,146 @@ import { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
hover?: boolean;
}
export function Card({ children, className = "" }: CardProps) {
export function Card({ children, className = "", hover = true }: CardProps) {
return (
<div className={`bg-card rounded-xl shadow-soft border border-line p-6 ${className}`}>
<div
className={`
bg-card text-card-foreground
rounded-lg border border-border/60
shadow-sm
transition-all duration-200 ease-out
${hover ? "hover:shadow-md hover:-translate-y-0.5" : ""}
${className}
`}
>
{children}
</div>
);
}
interface CardHeaderProps {
title: string;
children: ReactNode;
className?: string;
}
export function CardHeader({ title, className = "" }: CardHeaderProps) {
export function CardHeader({ children, className = "" }: CardHeaderProps) {
return (
<h2 className={`text-lg font-semibold text-foreground mb-4 ${className}`}>
{title}
</h2>
<div className={`flex flex-col space-y-1.5 p-6 ${className}`}>
{children}
</div>
);
}
interface CardTitleProps {
children: ReactNode;
className?: string;
}
export function CardTitle({ children, className = "" }: CardTitleProps) {
return (
<h3 className={`text-2xl font-semibold leading-none tracking-tight ${className}`}>
{children}
</h3>
);
}
interface CardDescriptionProps {
children: ReactNode;
className?: string;
}
export function CardDescription({ children, className = "" }: CardDescriptionProps) {
return (
<p className={`text-sm text-muted-foreground ${className}`}>
{children}
</p>
);
}
interface CardContentProps {
children: ReactNode;
className?: string;
}
export function CardContent({ children, className = "" }: CardContentProps) {
return (
<div className={`p-6 pt-0 ${className}`}>
{children}
</div>
);
}
interface CardFooterProps {
children: ReactNode;
className?: string;
}
export function CardFooter({ children, className = "" }: CardFooterProps) {
return (
<div className={`flex items-center p-6 pt-0 ${className}`}>
{children}
</div>
);
}
// Glass Card variant for special sections
interface GlassCardProps {
children: ReactNode;
className?: string;
}
export function GlassCard({ children, className = "" }: GlassCardProps) {
return (
<div
className={`
glass-card
rounded-xl
p-6
transition-all duration-200 ease-out
hover:shadow-elevation-2
${className}
`}
>
{children}
</div>
);
}
// Simple card with header shortcut
interface SimpleCardProps {
title?: string;
description?: string;
children: ReactNode;
className?: string;
footer?: ReactNode;
}
export function SimpleCard({
title,
description,
children,
className = "",
footer
}: SimpleCardProps) {
return (
<Card className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent>
{children}
</CardContent>
{footer && (
<CardFooter>
{footer}
</CardFooter>
)}
</Card>
);
}

View File

@@ -1,45 +1,81 @@
import { ReactNode, LabelHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes } from "react";
// Form Field Container
interface FormFieldProps {
children: ReactNode;
className?: string;
}
export function FormField({ children, className = "" }: FormFieldProps) {
return <div className={`flex-1 min-w-48 ${className}`}>{children}</div>;
return <div className={`flex flex-col space-y-1.5 ${className}`}>{children}</div>;
}
// Form Label
interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
children: ReactNode;
required?: boolean;
}
export function FormLabel({ children, className = "", ...props }: FormLabelProps) {
export function FormLabel({ children, required, className = "", ...props }: FormLabelProps) {
return (
<label className={`block text-sm font-medium text-foreground mb-1.5 ${className}`} {...props}>
<label
className={`text-sm font-medium text-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className}`}
{...props}
>
{children}
{required && <span className="text-destructive ml-1">*</span>}
</label>
);
}
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {}
// Form Input
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: string;
}
export function FormInput({ className = "", ...props }: FormInputProps) {
export function FormInput({ className = "", error, ...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}`}
className={`
flex h-10 w-full
rounded-md border border-input
bg-background px-3 py-2
text-sm
shadow-sm
transition-colors duration-200
file:border-0 file:bg-transparent file:text-sm file:font-medium
placeholder:text-muted-foreground/90
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50
${error ? "border-destructive focus-visible:ring-destructive" : ""}
${className}
`}
{...props}
/>
);
}
// Form Select
interface FormSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
children: ReactNode;
error?: string;
}
export function FormSelect({ children, className = "", ...props }: FormSelectProps) {
export function FormSelect({ children, className = "", error, ...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}`}
className={`
flex h-10 w-full
rounded-md border border-input
bg-background px-3 py-2
text-sm
shadow-sm
transition-colors duration-200
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50
${error ? "border-destructive focus-visible:ring-destructive" : ""}
${className}
`}
{...props}
>
{children}
@@ -47,11 +83,64 @@ export function FormSelect({ children, className = "", ...props }: FormSelectPro
);
}
// Form Row (horizontal layout)
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>;
return <div className={`flex flex-wrap items-end gap-4 ${className}`}>{children}</div>;
}
// Form Section
interface FormSectionProps {
title?: string;
description?: string;
children: ReactNode;
className?: string;
}
export function FormSection({ title, description, children, className = "" }: FormSectionProps) {
return (
<div className={`space-y-4 ${className}`}>
{(title || description) && (
<div className="space-y-1">
{title && <h3 className="text-lg font-medium text-foreground">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
)}
<div className="space-y-4">
{children}
</div>
</div>
);
}
// Form Error Message
interface FormErrorProps {
children: ReactNode;
className?: string;
}
export function FormError({ children, className = "" }: FormErrorProps) {
return (
<p className={`text-xs text-destructive ${className}`}>
{children}
</p>
);
}
// Form Description
interface FormDescriptionProps {
children: ReactNode;
className?: string;
}
export function FormDescription({ children, className = "" }: FormDescriptionProps) {
return (
<p className={`text-xs text-muted-foreground ${className}`}>
{children}
</p>
);
}

View File

@@ -1,30 +1,168 @@
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from "react";
import { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes, ReactNode, forwardRef } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
// Input Component
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: 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}
/>
);
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, className = "", ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-foreground mb-1.5">
{label}
</label>
)}
<input
ref={ref}
className={`
flex w-full
h-10 px-3 py-2
rounded-md border border-input
bg-background
text-sm text-foreground
shadow-sm
transition-colors duration-200
file:border-0 file:bg-transparent file:text-sm file:font-medium
placeholder:text-muted-foreground/90
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50
${error ? "border-destructive focus-visible:ring-destructive" : ""}
${className}
`}
{...props}
/>
{error && (
<p className="text-xs text-destructive mt-1">{error}</p>
)}
</div>
);
}
);
Input.displayName = "Input";
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
// Select Component
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: 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>
);
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ label, error, children, className = "", ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-foreground mb-1.5">
{label}
</label>
)}
<select
ref={ref}
className={`
flex w-full
h-10 px-3 py-2
rounded-md border border-input
bg-background
text-sm text-foreground
shadow-sm
transition-colors duration-200
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50
${error ? "border-destructive focus-visible:ring-destructive" : ""}
${className}
`}
{...props}
>
{children}
</select>
{error && (
<p className="text-xs text-destructive mt-1">{error}</p>
)}
</div>
);
}
);
Select.displayName = "Select";
// Textarea Component
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ label, error, className = "", ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-foreground mb-1.5">
{label}
</label>
)}
<textarea
ref={ref}
className={`
flex w-full
min-h-[80px] px-3 py-2
rounded-md border border-input
bg-background
text-sm text-foreground
shadow-sm
transition-colors duration-200
placeholder:text-muted-foreground/90
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50
resize-vertical
${error ? "border-destructive focus-visible:ring-destructive" : ""}
${className}
`}
{...props}
/>
{error && (
<p className="text-xs text-destructive mt-1">{error}</p>
)}
</div>
);
}
);
Textarea.displayName = "Textarea";
// Search Input with Icon
interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode;
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ icon, className = "", ...props }, ref) => {
return (
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
{icon}
</div>
)}
<input
ref={ref}
className={`
flex w-full
h-10 pl-10 pr-4 py-2
rounded-md border border-input
bg-background
text-sm text-foreground
shadow-sm
transition-colors duration-200
placeholder:text-muted-foreground/90
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50
${className}
`}
{...props}
/>
</div>
);
}
);
SearchInput.displayName = "SearchInput";

View File

@@ -2,6 +2,7 @@
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "./Button";
import { IconButton } from "./Button";
interface CursorPaginationProps {
hasNextPage: boolean;
@@ -44,14 +45,14 @@ export function CursorPagination({
};
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-line">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
{/* Page size selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted">Show</span>
<span className="text-sm text-muted-foreground">Show</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
className="w-20 px-3 py-2 text-sm border border-line rounded-lg bg-background text-foreground"
className="w-20 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
@@ -59,33 +60,38 @@ export function CursorPagination({
</option>
))}
</select>
<span className="text-sm text-muted">per page</span>
<span className="text-sm text-muted-foreground">per page</span>
</div>
{/* Count info */}
<div className="text-sm text-muted">
<div className="text-sm text-muted-foreground">
Showing {currentCount} items
</div>
{/* Navigation */}
<div className="flex items-center gap-3">
{hasPrevPage && (
<Button
variant="secondary"
size="sm"
onClick={goToFirst}
>
First
</Button>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={goToFirst}
disabled={!hasPrevPage}
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
First
</Button>
<Button
variant="secondary"
variant="outline"
size="sm"
onClick={goToNext}
disabled={!hasNextPage}
>
Next
Next
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Button>
</div>
</div>
@@ -161,14 +167,14 @@ export function OffsetPagination({
};
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-line">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
{/* Page size selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted">Show</span>
<span className="text-sm text-muted-foreground">Show</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
className="w-20 px-3 py-2 text-sm border border-line rounded-lg bg-background text-foreground"
className="w-20 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
@@ -176,34 +182,37 @@ export function OffsetPagination({
</option>
))}
</select>
<span className="text-sm text-muted">per page</span>
<span className="text-sm text-muted-foreground">per page</span>
</div>
{/* Page info */}
<div className="text-sm text-muted">
<div className="text-sm text-muted-foreground">
{startItem}-{endItem} of {totalItems}
</div>
{/* Page navigation */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
<div className="flex items-center gap-1">
<IconButton
size="sm"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
title="Previous page"
>
</Button>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</IconButton>
{getPageNumbers().map((page, index) => (
<span key={index}>
{page === "..." ? (
<span className="px-3 py-2 text-sm text-muted">...</span>
<span className="px-3 py-2 text-sm text-muted-foreground">...</span>
) : (
<Button
variant={currentPage === page ? "primary" : "ghost"}
variant={currentPage === page ? "default" : "ghost"}
size="sm"
onClick={() => goToPage(page as number)}
className="min-w-[2.5rem]"
>
{page}
</Button>
@@ -211,14 +220,16 @@ export function OffsetPagination({
</span>
))}
<Button
variant="ghost"
<IconButton
size="sm"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
title="Next page"
>
</Button>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</IconButton>
</div>
</div>
);

View File

@@ -3,13 +3,21 @@ interface ProgressBarProps {
max?: number;
showLabel?: boolean;
size?: "sm" | "md" | "lg";
variant?: "default" | "success" | "warning" | "error";
className?: string;
}
const sizeStyles = {
sm: "h-1.5",
md: "h-2",
lg: "h-8",
lg: "h-4",
};
const variantStyles = {
default: "bg-primary",
success: "bg-success",
warning: "bg-warning",
error: "bg-destructive",
};
export function ProgressBar({
@@ -17,18 +25,19 @@ export function ProgressBar({
max = 100,
showLabel = false,
size = "md",
variant = "default",
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={`relative ${sizeStyles[size]} bg-muted/50 rounded-full overflow-hidden ${className}`}>
<div
className="absolute inset-y-0 left-0 bg-success rounded-full transition-all duration-300"
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-500 ease-out ${variantStyles[variant]}`}
style={{ width: `${percent}%` }}
/>
{showLabel && (
<span className="absolute inset-0 flex items-center justify-center text-sm font-semibold text-foreground">
<span className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-foreground">
{Math.round(percent)}%
</span>
)}
@@ -36,21 +45,112 @@ export function ProgressBar({
);
}
// Mini Progress Bar (for compact displays)
interface MiniProgressBarProps {
value: number;
max?: number;
variant?: "default" | "success" | "warning" | "error";
className?: string;
}
export function MiniProgressBar({ value, max = 100, className = "" }: MiniProgressBarProps) {
export function MiniProgressBar({
value,
max = 100,
variant = "default",
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={`flex-1 h-1.5 bg-muted/50 rounded-full overflow-hidden ${className}`}>
<div
className="h-full bg-success rounded-full transition-all duration-300"
className={`h-full rounded-full transition-all duration-500 ease-out ${variantStyles[variant]}`}
style={{ width: `${percent}%` }}
/>
</div>
);
}
// Progress indicator with status colors based on percentage
interface SmartProgressBarProps {
value: number;
max?: number;
size?: "sm" | "md" | "lg";
className?: string;
}
export function SmartProgressBar({
value,
max = 100,
size = "md",
className = ""
}: SmartProgressBarProps) {
const percent = Math.min(100, Math.max(0, (value / max) * 100));
// Determine variant based on percentage
let variant: "default" | "success" | "warning" | "error" = "default";
if (percent === 100) variant = "success";
else if (percent < 25) variant = "error";
else if (percent < 50) variant = "warning";
return <ProgressBar value={value} max={max} size={size} variant={variant} className={className} />;
}
// Circular Progress (for special use cases)
interface CircularProgressProps {
value: number;
max?: number;
size?: number;
strokeWidth?: number;
className?: string;
}
export function CircularProgress({
value,
max = 100,
size = 40,
strokeWidth = 4,
className = ""
}: CircularProgressProps) {
const percent = Math.min(100, Math.max(0, (value / max) * 100));
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (percent / 100) * circumference;
// Determine color based on percentage
let color = "hsl(var(--color-primary))";
if (percent === 100) color = "hsl(var(--color-success))";
else if (percent < 25) color = "hsl(var(--color-destructive))";
else if (percent < 50) color = "hsl(var(--color-warning))";
return (
<div className={`relative inline-flex items-center justify-center ${className}`} style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
className="text-muted-foreground"
stroke="currentColor"
fill="transparent"
strokeWidth={strokeWidth}
r={radius}
cx={size / 2}
cy={size / 2}
/>
<circle
stroke={color}
fill="transparent"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
r={radius}
cx={size / 2}
cy={size / 2}
className="transition-all duration-500 ease-out"
/>
</svg>
<span className="absolute text-xs font-semibold text-foreground">
{Math.round(percent)}%
</span>
</div>
);
}

View File

@@ -8,11 +8,11 @@ interface StatBoxProps {
}
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",
default: "bg-muted/50",
primary: "bg-primary/10",
success: "bg-success/10",
warning: "bg-warning/10",
error: "bg-destructive/10",
};
const valueVariantStyles: Record<string, string> = {
@@ -20,14 +20,14 @@ const valueVariantStyles: Record<string, string> = {
primary: "text-primary",
success: "text-success",
warning: "text-warning",
error: "text-error",
error: "text-destructive",
};
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
return (
<div className={`text-center p-4 rounded-lg ${variantStyles[variant]} ${className}`}>
<div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}>
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
<span className={`text-xs ${valueVariantStyles[variant]}/80`}>{label}</span>
<span className={`text-xs text-muted-foreground`}>{label}</span>
</div>
);
}

View File

@@ -1,9 +1,21 @@
export { Card, CardHeader } from "./Card";
export { Badge, StatusBadge, JobTypeBadge } from "./Badge";
export {
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
GlassCard, SimpleCard
} from "./Card";
export {
Badge, StatusBadge, JobTypeBadge, ProgressBadge
} 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 {
ProgressBar, MiniProgressBar, SmartProgressBar, CircularProgress
} from "./ProgressBar";
export { Button, IconButton } from "./Button";
export {
Input, Select, Textarea, SearchInput
} from "./Input";
export {
FormField, FormLabel, FormInput, FormSelect, FormRow,
FormSection, FormError, FormDescription
} from "./Form";
export { PageIcon, NavIcon } from "./Icon";
export { CursorPagination, OffsetPagination } from "./Pagination";