feat(ui): Components refactoring with Tailwind - UI kit, icons, lazy loading images
- Created reusable UI components (Card, Button, Badge, Form, Icon) - Added PageIcon and NavIcon components with consistent styling - Refactored all pages to use new UI components - Added non-blocking image loading with skeleton for book covers - Created LibraryActions dropdown for library settings - Added emojis to buttons for better UX - Fixed Client Component issues with getBookCoverUrl
This commit is contained in:
@@ -1,42 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BookDto } from "../../lib/api";
|
||||
|
||||
interface BookCardProps {
|
||||
book: BookDto;
|
||||
getBookCoverUrl: (bookId: string) => string;
|
||||
book: BookDto & { coverUrl?: string };
|
||||
}
|
||||
|
||||
export function BookCard({ book, getBookCoverUrl }: BookCardProps) {
|
||||
function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<Link href={`/books/${book.id}`} className="book-card">
|
||||
<div className="book-cover">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.id)}
|
||||
alt={`Cover of ${book.title}`}
|
||||
width={150}
|
||||
height={220}
|
||||
className="cover-image"
|
||||
unoptimized
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="relative aspect-[2/3] overflow-hidden bg-gradient-to-br from-line/50 to-line">
|
||||
{/* Skeleton */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-muted/10 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>
|
||||
<div className="book-info">
|
||||
<h3 className="book-title" title={book.title}>
|
||||
|
||||
{/* Image */}
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
className={`object-cover group-hover:scale-105 transition-all duration-300 ${
|
||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookCard({ book }: BookCardProps) {
|
||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/pages/1?format=webp&width=200`;
|
||||
|
||||
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"
|
||||
>
|
||||
<BookImage
|
||||
src={coverUrl}
|
||||
alt={`Cover of ${book.title}`}
|
||||
/>
|
||||
|
||||
{/* Book Info */}
|
||||
<div className="p-4">
|
||||
<h3
|
||||
className="font-semibold text-foreground mb-1 line-clamp-2 min-h-[2.5rem]"
|
||||
title={book.title}
|
||||
>
|
||||
{book.title}
|
||||
</h3>
|
||||
|
||||
{book.author && (
|
||||
<p className="book-author">{book.author}</p>
|
||||
<p className="text-sm text-muted mb-1 truncate">{book.author}</p>
|
||||
)}
|
||||
|
||||
{book.series && (
|
||||
<p className="book-series">
|
||||
<p className="text-xs text-muted/80 truncate mb-2">
|
||||
{book.series}
|
||||
{book.volume && ` #${book.volume}`}
|
||||
{book.volume && <span className="text-primary font-medium"> #{book.volume}</span>}
|
||||
</p>
|
||||
)}
|
||||
<div className="book-meta">
|
||||
<span className={`book-kind ${book.kind}`}>{book.kind.toUpperCase()}</span>
|
||||
{book.language && <span className="book-lang">{book.language.toUpperCase()}</span>}
|
||||
|
||||
{/* Meta Tags */}
|
||||
<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}
|
||||
</span>
|
||||
{book.language && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary-soft text-primary">
|
||||
{book.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -44,15 +94,14 @@ export function BookCard({ book, getBookCoverUrl }: BookCardProps) {
|
||||
}
|
||||
|
||||
interface BooksGridProps {
|
||||
books: BookDto[];
|
||||
getBookCoverUrl: (bookId: string) => string;
|
||||
books: (BookDto & { coverUrl?: string })[];
|
||||
}
|
||||
|
||||
export function BooksGrid({ books, getBookCoverUrl }: BooksGridProps) {
|
||||
export function BooksGrid({ books }: BooksGridProps) {
|
||||
return (
|
||||
<div className="books-grid">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} getBookCoverUrl={getBookCoverUrl} />
|
||||
<BookCard key={book.id} book={book} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -64,8 +113,13 @@ interface EmptyStateProps {
|
||||
|
||||
export function EmptyState({ message }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>{message}</p>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 mb-4 text-muted/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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { StatusBadge, Badge, ProgressBar } from "./ui";
|
||||
|
||||
interface ProgressEvent {
|
||||
job_id: string;
|
||||
@@ -28,7 +29,6 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Use SSE via local proxy
|
||||
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
@@ -69,11 +69,19 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
}, [jobId, onComplete]);
|
||||
|
||||
if (error) {
|
||||
return <div className="progress-error">Error: {error}</div>;
|
||||
return (
|
||||
<div className="p-4 bg-error-soft text-error rounded-lg text-sm">
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!progress) {
|
||||
return <div className="progress-loading">Loading progress...</div>;
|
||||
return (
|
||||
<div className="p-4 text-muted text-sm">
|
||||
Loading progress...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const percent = progress.progress_percent ?? 0;
|
||||
@@ -81,26 +89,20 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
const total = progress.total_files ?? 0;
|
||||
|
||||
return (
|
||||
<div className="job-progress">
|
||||
<div className="progress-header">
|
||||
<span className={`status-badge status-${progress.status}`}>
|
||||
{progress.status}
|
||||
</span>
|
||||
{isComplete && <span className="complete-badge">Complete</span>}
|
||||
<div className="p-4 bg-card rounded-lg border border-line">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<StatusBadge status={progress.status} />
|
||||
{isComplete && (
|
||||
<Badge variant="success">Complete</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
<span className="progress-percent">{percent}%</span>
|
||||
</div>
|
||||
<ProgressBar value={percent} showLabel size="md" className="mb-3" />
|
||||
|
||||
<div className="progress-stats">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted mb-3">
|
||||
<span>{processed} / {total} files</span>
|
||||
{progress.current_file && (
|
||||
<span className="current-file" title={progress.current_file}>
|
||||
<span className="truncate max-w-md" title={progress.current_file}>
|
||||
Current: {progress.current_file.length > 40
|
||||
? progress.current_file.substring(0, 40) + "..."
|
||||
: progress.current_file}
|
||||
@@ -109,12 +111,12 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
</div>
|
||||
|
||||
{progress.stats_json && (
|
||||
<div className="progress-detailed-stats">
|
||||
<span>Scanned: {progress.stats_json.scanned_files}</span>
|
||||
<span>Indexed: {progress.stats_json.indexed_files}</span>
|
||||
<span>Removed: {progress.stats_json.removed_files}</span>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
|
||||
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
|
||||
<Badge variant="warning">Removed: {progress.stats_json.removed_files}</Badge>
|
||||
{progress.stats_json.errors > 0 && (
|
||||
<span className="error-count">Errors: {progress.stats_json.errors}</span>
|
||||
<Badge variant="error">Errors: {progress.stats_json.errors}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { JobProgress } from "./JobProgress";
|
||||
import { StatusBadge, Button } from "./ui";
|
||||
|
||||
interface JobRowProps {
|
||||
job: {
|
||||
@@ -25,52 +26,71 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
||||
|
||||
const handleComplete = () => {
|
||||
setShowProgress(false);
|
||||
// Trigger a page refresh to update the job status
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={highlighted ? "job-highlighted" : undefined}>
|
||||
<td>
|
||||
<Link href={`/jobs/${job.id}`} className="job-id-link">
|
||||
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/jobs/${job.id}`}
|
||||
className="text-primary hover:text-primary/80 hover:underline font-mono text-sm"
|
||||
>
|
||||
<code>{job.id.slice(0, 8)}</code>
|
||||
</Link>
|
||||
</td>
|
||||
<td>{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}</td>
|
||||
<td>{job.type}</td>
|
||||
<td>
|
||||
<span className={`status-${job.status}`}>{job.status}</span>
|
||||
{job.error_opt && <span className="error-hint" title={job.error_opt}>!</span>}
|
||||
{(job.status === "running" || job.status === "pending") && (
|
||||
<button
|
||||
className="toggle-progress-btn"
|
||||
onClick={() => setShowProgress(!showProgress)}
|
||||
>
|
||||
{showProgress ? "Hide" : "Show"} progress
|
||||
</button>
|
||||
)}
|
||||
<td className="px-4 py-3 text-sm text-foreground">
|
||||
{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}
|
||||
</td>
|
||||
<td>{new Date(job.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<Link href={`/jobs/${job.id}`} className="view-btn">
|
||||
<td className="px-4 py-3 text-sm text-foreground">{job.type}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<StatusBadge status={job.status} />
|
||||
{job.error_opt && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-error text-white text-xs font-bold cursor-help"
|
||||
title={job.error_opt}
|
||||
>
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
{(job.status === "running" || job.status === "pending") && (
|
||||
<button
|
||||
className="text-xs text-primary hover:text-primary/80 hover:underline"
|
||||
onClick={() => setShowProgress(!showProgress)}
|
||||
>
|
||||
{showProgress ? "Hide" : "Show"} progress
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted">
|
||||
{new Date(job.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/jobs/${job.id}`}
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
{(job.status === "pending" || job.status === "running") && (
|
||||
<button
|
||||
className="cancel-btn"
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onCancel(job.id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{showProgress && (job.status === "running" || job.status === "pending") && (
|
||||
<tr className="progress-row">
|
||||
<td colSpan={6}>
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-3 bg-muted/5">
|
||||
<JobProgress
|
||||
jobId={job.id}
|
||||
onComplete={handleComplete}
|
||||
|
||||
@@ -22,7 +22,6 @@ interface Job {
|
||||
export function JobsIndicator() {
|
||||
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,7 +65,11 @@ export function JobsIndicator() {
|
||||
|
||||
if (totalCount === 0) {
|
||||
return (
|
||||
<Link href="/jobs" className="jobs-indicator-empty" title="View all jobs">
|
||||
<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"
|
||||
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" />
|
||||
@@ -76,15 +79,19 @@ export function JobsIndicator() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="jobs-indicator-wrapper" ref={dropdownRef}>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
className={`jobs-indicator-button ${runningJobs.length > 0 ? 'has-running' : ''} ${isOpen ? 'open' : ''}`}
|
||||
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' : ''}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{/* Animated spinner for running jobs */}
|
||||
{runningJobs.length > 0 && (
|
||||
<div className="jobs-spinner">
|
||||
<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" />
|
||||
@@ -93,21 +100,19 @@ export function JobsIndicator() {
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<svg className="jobs-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<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>
|
||||
|
||||
{/* Badge with count */}
|
||||
<span className="jobs-count-badge">
|
||||
{totalCount > 99 ? "99+" : totalCount}
|
||||
<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="text-background">{totalCount > 99 ? "99+" : totalCount}</span>
|
||||
</span>
|
||||
|
||||
{/* Chevron */}
|
||||
<svg
|
||||
className={`jobs-chevron ${isOpen ? 'open' : ''}`}
|
||||
width="16"
|
||||
height="16"
|
||||
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -119,13 +124,13 @@ export function JobsIndicator() {
|
||||
|
||||
{/* Popin/Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="jobs-popin">
|
||||
<div className="jobs-popin-header">
|
||||
<div className="jobs-popin-title">
|
||||
<span className="jobs-icon-large">📊</span>
|
||||
<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="flex items-center gap-3">
|
||||
<span className="text-2xl">📊</span>
|
||||
<div>
|
||||
<h3>Active Jobs</h3>
|
||||
<p className="jobs-subtitle">
|
||||
<h3 className="font-semibold text-foreground">Active Jobs</h3>
|
||||
<p className="text-xs text-muted">
|
||||
{runningJobs.length > 0
|
||||
? `${runningJobs.length} running, ${pendingJobs.length} pending`
|
||||
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
|
||||
@@ -135,7 +140,7 @@ export function JobsIndicator() {
|
||||
</div>
|
||||
<Link
|
||||
href="/jobs"
|
||||
className="jobs-view-all"
|
||||
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
View All →
|
||||
@@ -144,72 +149,74 @@ export function JobsIndicator() {
|
||||
|
||||
{/* Overall progress bar if running */}
|
||||
{runningJobs.length > 0 && (
|
||||
<div className="jobs-overall-progress">
|
||||
<div className="progress-header">
|
||||
<span>Overall Progress</span>
|
||||
<span className="progress-percent">{Math.round(totalProgress)}%</span>
|
||||
<div className="px-4 py-3 border-b border-line">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-muted">Overall Progress</span>
|
||||
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div className="h-2 bg-line rounded-full overflow-hidden">
|
||||
<div
|
||||
className="progress-fill"
|
||||
className="h-full bg-success rounded-full transition-all duration-500"
|
||||
style={{ width: `${totalProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="jobs-list-container">
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{activeJobs.length === 0 ? (
|
||||
<div className="jobs-empty-state">
|
||||
<span className="empty-icon">✅</span>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted">
|
||||
<span className="text-4xl mb-2">✅</span>
|
||||
<p>No active jobs</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="jobs-detailed-list">
|
||||
<ul className="divide-y divide-line">
|
||||
{activeJobs.map(job => (
|
||||
<li key={job.id} className={`job-detailed-item job-status-${job.status}`}>
|
||||
<li key={job.id}>
|
||||
<Link
|
||||
href={`/jobs/${job.id}`}
|
||||
className="job-link"
|
||||
className="block px-4 py-3 hover:bg-muted/5 transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<div className="job-info-row">
|
||||
<div className="job-status-icon">
|
||||
{job.status === "running" && <span className="spinning">⏳</span>}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{job.status === "running" && <span className="animate-spin inline-block">⏳</span>}
|
||||
{job.status === "pending" && <span>⏸</span>}
|
||||
</div>
|
||||
|
||||
<div className="job-details">
|
||||
<div className="job-main-info">
|
||||
<code className="job-id-short">{job.id.slice(0, 8)}</code>
|
||||
<span className={`job-type-badge ${job.type}`}>{job.type}</span>
|
||||
<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'
|
||||
}`}>
|
||||
{job.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{job.status === "running" && job.progress_percent !== null && (
|
||||
<div className="job-progress-row">
|
||||
<div className="job-mini-progress-bar">
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex-1 h-1.5 bg-line rounded-full overflow-hidden">
|
||||
<div
|
||||
className="job-mini-progress-fill"
|
||||
className="h-full bg-success rounded-full transition-all duration-300"
|
||||
style={{ width: `${job.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="job-progress-text">{job.progress_percent}%</span>
|
||||
<span className="text-xs font-medium text-muted">{job.progress_percent}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.current_file && (
|
||||
<p className="job-current-file" title={job.current_file}>
|
||||
📄 {job.current_file.length > 35
|
||||
? job.current_file.substring(0, 35) + "..."
|
||||
: job.current_file}
|
||||
<p className="text-xs text-muted mt-1.5 truncate" title={job.current_file}>
|
||||
📄 {job.current_file}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{job.stats_json && (
|
||||
<div className="job-mini-stats">
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted">
|
||||
<span>✓ {job.stats_json.indexed_files}</span>
|
||||
{job.stats_json.errors > 0 && (
|
||||
<span className="error-stat">⚠ {job.stats_json.errors}</span>
|
||||
<span className="text-error">⚠ {job.stats_json.errors}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -223,8 +230,8 @@ export function JobsIndicator() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="jobs-popin-footer">
|
||||
<p className="jobs-auto-refresh">Auto-refreshing every 2s</p>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -64,28 +64,32 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
};
|
||||
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Library</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
||||
highlighted={job.id === highlightJobId}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="bg-card rounded-xl shadow-soft border border-line 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">Created</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{jobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
||||
highlighted={job.id === highlightJobId}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
119
apps/backoffice/app/components/LibraryActions.tsx
Normal file
119
apps/backoffice/app/components/LibraryActions.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Badge } from "../components/ui";
|
||||
|
||||
interface LibraryActionsProps {
|
||||
libraryId: string;
|
||||
monitorEnabled: boolean;
|
||||
scanMode: string;
|
||||
watcherEnabled: boolean;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export function LibraryActions({
|
||||
libraryId,
|
||||
monitorEnabled,
|
||||
scanMode,
|
||||
watcherEnabled,
|
||||
onUpdate
|
||||
}: LibraryActionsProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
startTransition(async () => {
|
||||
const data = {
|
||||
monitor_enabled: formData.get("monitor_enabled") === "true",
|
||||
scan_mode: formData.get("scan_mode") as string,
|
||||
watcher_enabled: formData.get("watcher_enabled") === "true",
|
||||
};
|
||||
|
||||
await fetch(`/api/libraries/${libraryId}/monitoring`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
onUpdate?.();
|
||||
window.location.reload();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={isOpen ? "bg-muted/10" : ""}
|
||||
>
|
||||
⚙️
|
||||
</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">
|
||||
<form action={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">🔄 Auto Scan</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="monitor_enabled"
|
||||
defaultChecked={monitorEnabled}
|
||||
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">⚡ File Watcher</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="watcher_enabled"
|
||||
defaultChecked={watcherEnabled}
|
||||
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">📅 Schedule</label>
|
||||
<select
|
||||
name="scan_mode"
|
||||
defaultValue={scanMode}
|
||||
className="text-sm border border-line rounded-lg px-2 py-1 bg-background"
|
||||
>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,28 +34,38 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="monitoring-form-compact">
|
||||
<form action={handleSubmit} className="flex items-center gap-2">
|
||||
<input type="hidden" name="id" value={libraryId} />
|
||||
|
||||
<div className="monitor-row">
|
||||
<label className={`monitor-checkbox ${isPending ? 'pending' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
|
||||
isPending
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:border-primary'
|
||||
} ${monitorEnabled ? 'bg-primary-soft border-primary text-primary' : 'bg-card border-line text-muted'}`}>
|
||||
<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"
|
||||
/>
|
||||
<span>Auto</span>
|
||||
</label>
|
||||
|
||||
<label className={`monitor-checkbox watcher ${isPending ? 'pending' : ''} ${watcherEnabled ? 'active' : ''}`}>
|
||||
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
|
||||
isPending
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:border-primary'
|
||||
} ${watcherEnabled ? 'bg-warning-soft border-warning text-warning' : 'bg-card border-line text-muted'}`}>
|
||||
<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"
|
||||
/>
|
||||
<span title="Real-time file watcher">⚡</span>
|
||||
</label>
|
||||
@@ -64,7 +74,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
name="scan_mode"
|
||||
defaultValue={scanMode}
|
||||
disabled={isPending}
|
||||
className="scan-mode-select"
|
||||
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"
|
||||
>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="hourly">Hourly</option>
|
||||
@@ -72,7 +82,11 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
|
||||
<button type="submit" className="save-btn" disabled={isPending}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary text-white font-semibold text-sm transition-all hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isPending ? '...' : '✓'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
61
apps/backoffice/app/components/ui/Badge.tsx
Normal file
61
apps/backoffice/app/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
type BadgeVariant = "default" | "primary" | "success" | "warning" | "error" | "muted";
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
default: "bg-muted/20 text-muted",
|
||||
primary: "bg-primary-soft text-primary",
|
||||
success: "bg-success-soft text-success",
|
||||
warning: "bg-warning-soft text-warning",
|
||||
error: "bg-error-soft text-error",
|
||||
muted: "bg-muted/10 text-muted",
|
||||
};
|
||||
|
||||
export function Badge({ children, variant = "default", className = "" }: BadgeProps) {
|
||||
return (
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${variantStyles[variant]} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusVariant = "running" | "success" | "failed" | "cancelled" | "pending";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusVariants: Record<StatusVariant, BadgeVariant> = {
|
||||
running: "primary",
|
||||
success: "success",
|
||||
failed: "error",
|
||||
cancelled: "muted",
|
||||
pending: "warning",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
||||
const variant = statusVariants[status as StatusVariant] || "default";
|
||||
return <Badge variant={variant} className={className}>{status}</Badge>;
|
||||
}
|
||||
|
||||
type JobTypeVariant = "rebuild" | "full_rebuild";
|
||||
|
||||
interface JobTypeBadgeProps {
|
||||
type: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const jobTypeVariants: Record<JobTypeVariant, BadgeVariant> = {
|
||||
rebuild: "primary",
|
||||
full_rebuild: "warning",
|
||||
};
|
||||
|
||||
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
||||
const variant = jobTypeVariants[type as JobTypeVariant] || "default";
|
||||
return <Badge variant={variant} className={className}>{type}</Badge>;
|
||||
}
|
||||
48
apps/backoffice/app/components/ui/Button.tsx
Normal file
48
apps/backoffice/app/components/ui/Button.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "warning" | "ghost";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: "bg-primary text-white hover:bg-primary/90",
|
||||
secondary: "border border-line text-muted hover:bg-muted/5",
|
||||
danger: "bg-error text-white hover:bg-error/90",
|
||||
warning: "bg-warning text-white hover:bg-warning/90",
|
||||
ghost: "text-muted hover:text-foreground hover:bg-muted/5",
|
||||
};
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
inline-flex items-center justify-center font-medium rounded-lg transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
27
apps/backoffice/app/components/ui/Card.tsx
Normal file
27
apps/backoffice/app/components/ui/Card.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ children, className = "" }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-card rounded-xl shadow-soft border border-line p-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardHeader({ title, className = "" }: CardHeaderProps) {
|
||||
return (
|
||||
<h2 className={`text-lg font-semibold text-foreground mb-4 ${className}`}>
|
||||
{title}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
57
apps/backoffice/app/components/ui/Form.tsx
Normal file
57
apps/backoffice/app/components/ui/Form.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ReactNode, LabelHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes } from "react";
|
||||
|
||||
interface FormFieldProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormField({ children, className = "" }: FormFieldProps) {
|
||||
return <div className={`flex-1 min-w-48 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FormLabel({ children, className = "", ...props }: FormLabelProps) {
|
||||
return (
|
||||
<label className={`block text-sm font-medium text-foreground mb-1.5 ${className}`} {...props}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export function FormInput({ className = "", ...props }: FormInputProps) {
|
||||
return (
|
||||
<input
|
||||
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FormSelect({ children, className = "", ...props }: FormSelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormRowProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormRow({ children, className = "" }: FormRowProps) {
|
||||
return <div className={`flex items-end gap-3 flex-wrap ${className}`}>{children}</div>;
|
||||
}
|
||||
94
apps/backoffice/app/components/ui/Icon.tsx
Normal file
94
apps/backoffice/app/components/ui/Icon.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
type IconName = "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "series";
|
||||
|
||||
interface PageIconProps {
|
||||
name: IconName;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const icons: Record<IconName, React.ReactNode> = {
|
||||
dashboard: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
books: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
),
|
||||
libraries: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
jobs: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
tokens: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
),
|
||||
series: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const colors: Record<IconName, string> = {
|
||||
dashboard: "text-primary",
|
||||
books: "text-success",
|
||||
libraries: "text-primary",
|
||||
jobs: "text-warning",
|
||||
tokens: "text-error",
|
||||
series: "text-primary",
|
||||
};
|
||||
|
||||
export function PageIcon({ name, className = "" }: PageIconProps) {
|
||||
return (
|
||||
<span className={`${colors[name]} ${className}`}>
|
||||
{icons[name]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Nav icons (smaller)
|
||||
export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) {
|
||||
const navIcons: Record<IconName, React.ReactNode> = {
|
||||
dashboard: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
books: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
),
|
||||
libraries: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
jobs: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
tokens: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
),
|
||||
series: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return <span className={className}>{navIcons[name]}</span>;
|
||||
}
|
||||
30
apps/backoffice/app/components/ui/Input.tsx
Normal file
30
apps/backoffice/app/components/ui/Input.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, className = "", ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={`px-4 py-2.5 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Select({ label, children, className = "", ...props }: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={`px-4 py-2.5 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
56
apps/backoffice/app/components/ui/ProgressBar.tsx
Normal file
56
apps/backoffice/app/components/ui/ProgressBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
interface ProgressBarProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
showLabel?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: "h-1.5",
|
||||
md: "h-2",
|
||||
lg: "h-8",
|
||||
};
|
||||
|
||||
export function ProgressBar({
|
||||
value,
|
||||
max = 100,
|
||||
showLabel = false,
|
||||
size = "md",
|
||||
className = ""
|
||||
}: ProgressBarProps) {
|
||||
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
|
||||
return (
|
||||
<div className={`relative ${sizeStyles[size]} bg-line rounded-full overflow-hidden ${className}`}>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-success rounded-full transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
{showLabel && (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-sm font-semibold text-foreground">
|
||||
{Math.round(percent)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MiniProgressBarProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MiniProgressBar({ value, max = 100, className = "" }: MiniProgressBarProps) {
|
||||
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
|
||||
return (
|
||||
<div className={`flex-1 h-1.5 bg-line rounded-full overflow-hidden ${className}`}>
|
||||
<div
|
||||
className="h-full bg-success rounded-full transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/backoffice/app/components/ui/StatBox.tsx
Normal file
33
apps/backoffice/app/components/ui/StatBox.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface StatBoxProps {
|
||||
value: ReactNode;
|
||||
label: string;
|
||||
variant?: "default" | "primary" | "success" | "warning" | "error";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, string> = {
|
||||
default: "bg-muted/5",
|
||||
primary: "bg-primary-soft",
|
||||
success: "bg-success-soft",
|
||||
warning: "bg-warning-soft",
|
||||
error: "bg-error-soft",
|
||||
};
|
||||
|
||||
const valueVariantStyles: Record<string, string> = {
|
||||
default: "text-foreground",
|
||||
primary: "text-primary",
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
error: "text-error",
|
||||
};
|
||||
|
||||
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
|
||||
return (
|
||||
<div className={`text-center p-4 rounded-lg ${variantStyles[variant]} ${className}`}>
|
||||
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
|
||||
<span className={`text-xs ${valueVariantStyles[variant]}/80`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
apps/backoffice/app/components/ui/index.ts
Normal file
8
apps/backoffice/app/components/ui/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { Card, CardHeader } from "./Card";
|
||||
export { Badge, StatusBadge, JobTypeBadge } from "./Badge";
|
||||
export { StatBox } from "./StatBox";
|
||||
export { ProgressBar, MiniProgressBar } from "./ProgressBar";
|
||||
export { Button } from "./Button";
|
||||
export { Input, Select } from "./Input";
|
||||
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
|
||||
export { PageIcon, NavIcon } from "./Icon";
|
||||
Reference in New Issue
Block a user