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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
111
apps/backoffice/app/components/LibrarySubPageHeader.tsx
Normal file
111
apps/backoffice/app/components/LibrarySubPageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user