Compare commits

..

2 Commits

Author SHA1 Message Date
7cdc72b6e1 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
2026-03-06 16:21:48 +01:00
2b30ae47de build: Docker compose build successful with all services
- Fixed Dockerfiles (removed admin-ui references)
- Updated Cargo.toml workspace
- Added @tailwindcss/postcss dependency
- All services building and running correctly
2026-03-06 15:18:03 +01:00
35 changed files with 2460 additions and 700 deletions

View File

@@ -2,7 +2,6 @@
members = [
"apps/api",
"apps/indexer",
"apps/admin-ui",
"crates/core",
"crates/parsers",
]

View File

@@ -4,12 +4,10 @@ WORKDIR /app
COPY Cargo.toml ./
COPY apps/api/Cargo.toml apps/api/Cargo.toml
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
COPY crates/core/Cargo.toml crates/core/Cargo.toml
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
COPY apps/api/src apps/api/src
COPY apps/indexer/src apps/indexer/src
COPY apps/admin-ui/src apps/admin-ui/src
COPY crates/core/src crates/core/src
COPY crates/parsers/src crates/parsers/src

View File

@@ -33,14 +33,14 @@ export default async function BookDetailPage({
return (
<>
<div className="mb-6">
<Link href="/books" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
<Link href="/books" className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors">
Back to books
</Link>
</div>
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-shrink-0">
<div className="bg-card rounded-xl shadow-card border border-line p-4 inline-block">
<div className="bg-card rounded-xl shadow-card border border-border p-4 inline-block">
<Image
src={getBookCoverUrl(book.id)}
alt={`Cover of ${book.title}`}
@@ -54,76 +54,76 @@ export default async function BookDetailPage({
</div>
<div className="flex-1">
<div className="bg-card rounded-xl shadow-soft border border-line p-6">
<div className="bg-card rounded-xl shadow-sm border border-border p-6">
<h1 className="text-3xl font-bold text-foreground mb-2">{book.title}</h1>
{book.author && (
<p className="text-lg text-muted mb-4">by {book.author}</p>
<p className="text-lg text-muted-foreground mb-4">by {book.author}</p>
)}
{book.series && (
<p className="text-sm text-muted mb-6">
<p className="text-sm text-muted-foreground mb-6">
{book.series}
{book.volume && <span className="ml-2 px-2 py-1 bg-primary-soft text-primary rounded text-xs">Volume {book.volume}</span>}
{book.volume && <span className="ml-2 px-2 py-1 bg-primary/10 text-primary rounded text-xs">Volume {book.volume}</span>}
</p>
)}
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Format:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Format:</span>
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
book.kind === 'epub' ? 'bg-primary-soft text-primary' : 'bg-muted/20 text-muted'
book.kind === 'epub' ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
}`}>
{book.kind.toUpperCase()}
</span>
</div>
{book.volume && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Volume:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Volume:</span>
<span className="text-sm text-foreground">{book.volume}</span>
</div>
)}
{book.language && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Language:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Language:</span>
<span className="text-sm text-foreground">{book.language.toUpperCase()}</span>
</div>
)}
{book.page_count && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Pages:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Pages:</span>
<span className="text-sm text-foreground">{book.page_count}</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Library:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Library:</span>
<span className="text-sm text-foreground">{library?.name || book.library_id}</span>
</div>
{book.series && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Series:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Series:</span>
<span className="text-sm text-foreground">{book.series}</span>
</div>
)}
{book.file_format && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">File Format:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">File Format:</span>
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
</div>
)}
{book.file_parse_status && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Parse Status:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Parse Status:</span>
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
book.file_parse_status === 'success' ? 'bg-success-soft text-success' :
book.file_parse_status === 'failed' ? 'bg-error-soft text-error' : 'bg-muted/20 text-muted'
book.file_parse_status === 'success' ? 'bg-success/10 text-success' :
book.file_parse_status === 'failed' ? 'bg-destructive/10 text-error' : 'bg-muted/50 text-muted-foreground'
}`}>
{book.file_parse_status}
</span>
@@ -131,25 +131,25 @@ export default async function BookDetailPage({
)}
{book.file_path && (
<div className="flex flex-col py-2 border-b border-line">
<span className="text-sm text-muted mb-1">File Path:</span>
<div className="flex flex-col py-2 border-b border-border">
<span className="text-sm text-muted-foreground mb-1">File Path:</span>
<code className="text-xs font-mono text-foreground break-all">{book.file_path}</code>
</div>
)}
<div className="flex flex-col py-2 border-b border-line">
<span className="text-sm text-muted mb-1">Book ID:</span>
<div className="flex flex-col py-2 border-b border-border">
<span className="text-sm text-muted-foreground mb-1">Book ID:</span>
<code className="text-xs font-mono text-foreground break-all">{book.id}</code>
</div>
<div className="flex flex-col py-2 border-b border-line">
<span className="text-sm text-muted mb-1">Library ID:</span>
<div className="flex flex-col py-2 border-b border-border">
<span className="text-sm text-muted-foreground mb-1">Library ID:</span>
<code className="text-xs font-mono text-foreground break-all">{book.library_id}</code>
</div>
{book.updated_at && (
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted">Updated:</span>
<span className="text-sm text-muted-foreground">Updated:</span>
<span className="text-sm text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
</div>
)}

View File

@@ -1,6 +1,6 @@
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
import { BooksGrid, EmptyState } from "../components/BookCard";
import { Card, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui";
import { Card, CardContent, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui";
import Link from "next/link";
export const dynamic = "force-dynamic";
@@ -26,7 +26,6 @@ export default async function BooksPage({
let totalHits: number | null = null;
if (searchQuery) {
// Mode recherche
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
if (searchResponse) {
searchResults = searchResponse.hits.map(hit => ({
@@ -47,7 +46,6 @@ export default async function BooksPage({
totalHits = searchResponse.estimated_total_hits;
}
} else {
// Mode liste avec pagination
const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({
items: [] as BookDto[],
next_cursor: null,
@@ -55,7 +53,6 @@ export default async function BooksPage({
}));
books = booksPage.items;
nextCursor = booksPage.next_cursor;
// Note: L'API ne supporte pas encore prev_cursor, on gère ça côté UI
}
const displayBooks = (searchResults || books).map(book => ({
@@ -64,27 +61,34 @@ export default async function BooksPage({
}));
const hasNextPage = !!nextCursor;
const hasPrevPage = !!cursor; // Si on a un cursor, on peut revenir en arrière (simplifié)
const hasPrevPage = !!cursor;
return (
<>
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
<svg className="w-8 h-8 text-success" 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>
Books
</h1>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-success" 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>
Books
</h1>
</div>
{/* Filtres et recherche */}
{/* Search Bar - Style compact et propre */}
<Card className="mb-6">
<form>
<FormRow>
<FormField>
<CardContent className="pt-6">
<form className="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<FormField className="flex-1 w-full">
<label className="block text-sm font-medium text-foreground mb-1.5">Search</label>
<FormInput
name="q"
placeholder="Search books..."
placeholder="Search by title, author, series..."
defaultValue={searchQuery}
className="w-full"
/>
</FormField>
<FormField>
<FormField className="w-full sm:w-48">
<label className="block text-sm font-medium text-foreground mb-1.5">Library</label>
<FormSelect name="library" defaultValue={libraryId || ""}>
<option value="">All libraries</option>
{libraries.map((lib) => (
@@ -94,22 +98,40 @@ export default async function BooksPage({
))}
</FormSelect>
</FormField>
<Button type="submit">🔍 Search</Button>
{searchQuery && (
<Link
href="/books"
className="px-4 py-2.5 border border-line text-muted font-medium rounded-lg hover:bg-muted/5 transition-colors"
>
Clear
</Link>
)}
</FormRow>
</form>
<div className="flex gap-2 w-full sm:w-auto">
<Button type="submit" className="flex-1 sm:flex-none">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Search
</Button>
{searchQuery && (
<Link
href="/books"
className="
inline-flex items-center justify-center
h-10 px-4
border border-input
text-sm font-medium
text-muted-foreground
bg-background
rounded-md
hover:bg-accent hover:text-accent-foreground
transition-colors duration-200
flex-1 sm:flex-none
"
>
Clear
</Link>
)}
</div>
</form>
</CardContent>
</Card>
{/* Résultats de recherche */}
{/* Résultats */}
{searchQuery && totalHits !== null && (
<p className="text-sm text-muted mb-4">
<p className="text-sm text-muted-foreground mb-4">
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for &quot;{searchQuery}&quot;
</p>
)}
@@ -119,7 +141,6 @@ export default async function BooksPage({
<>
<BooksGrid books={displayBooks} />
{/* Pagination */}
{!searchQuery && (
<CursorPagination
hasNextPage={hasNextPage}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
import Link from "next/link";
import { Card, Badge } from "./ui";
interface LibrarySubPageHeaderProps {
library: {
id: string;
name: string;
root_path: string;
book_count: number;
enabled: boolean;
};
title: string;
icon: React.ReactNode;
iconColor?: string;
filterInfo?: {
label: string;
clearHref: string;
clearLabel: string;
};
}
export function LibrarySubPageHeader({
library,
title,
icon,
iconColor = "text-primary",
filterInfo
}: LibrarySubPageHeaderProps) {
return (
<div className="space-y-6">
{/* Header avec breadcrumb intégré */}
<div>
<div className="flex items-center gap-2 mb-2">
<Link
href="/libraries"
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Libraries
</Link>
<span className="text-muted-foreground">/</span>
<span className="text-sm text-foreground font-medium">{library.name}</span>
</div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<span className={iconColor}>{icon}</span>
{title}
</h1>
</div>
{/* Info Bar - Version améliorée */}
<Card className="bg-muted/30 border-border/40">
<div className="p-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
{/* Path */}
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<code className="text-xs font-mono text-muted-foreground bg-background px-2 py-1 rounded border border-border/60">
{library.root_path}
</code>
</div>
{/* Divider */}
<span className="hidden sm:block w-px h-4 bg-border" />
{/* Book count */}
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<span className="text-foreground">
<span className="font-semibold">{library.book_count}</span>
<span className="text-muted-foreground ml-1">book{library.book_count !== 1 ? 's' : ''}</span>
</span>
</div>
{/* Divider */}
<span className="hidden sm:block w-px h-4 bg-border" />
{/* Status */}
<Badge
variant={library.enabled ? "success" : "muted"}
className="text-xs"
>
{library.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</div>
</Card>
{/* Filter Info (optionnel) */}
{filterInfo && (
<div className="flex items-center justify-between py-2">
<p className="text-muted-foreground text-sm">
{filterInfo.label}
</p>
<Link
href={filterInfo.clearHref as `/libraries/${string}/books`}
className="text-sm text-primary hover:text-primary/80 font-medium"
>
{filterInfo.clearLabel}
</Link>
</div>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,43 @@
@import "tailwindcss";
@theme {
/* Core Colors - Light Theme */
--color-background: hsl(36 33% 97%);
--color-foreground: hsl(222 33% 15%);
/* Card & Surfaces */
--color-card: hsl(0 0% 100%);
--color-line: hsl(32 18% 84%);
--color-line-strong: hsl(32 18% 76%);
--color-card-foreground: hsl(222 33% 15%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222 33% 15%);
/* Primary - Cyan/Teal */
--color-primary: hsl(198 78% 37%);
--color-primary-foreground: hsl(210 40% 98%);
--color-primary-soft: hsl(198 52% 90%);
--color-muted: hsl(220 13% 40%);
/* Secondary - Warm Gray */
--color-secondary: hsl(36 30% 92%);
--color-secondary-foreground: hsl(222 33% 15%);
/* Muted */
--color-muted: hsl(36 24% 90%);
--color-muted-foreground: hsl(220 13% 40%);
/* Accent */
--color-accent: hsl(198 52% 90%);
--color-accent-foreground: hsl(222 33% 15%);
/* Destructive - Red */
--color-destructive: hsl(2 72% 48%);
--color-destructive-foreground: hsl(210 40% 98%);
/* Borders */
--color-border: hsl(32 18% 84%);
--color-input: hsl(32 18% 84%);
--color-ring: hsl(198 78% 37%);
/* Status Colors */
--color-success: hsl(142 60% 45%);
--color-success-soft: hsl(142 60% 90%);
--color-warning: hsl(45 93% 47%);
@@ -16,57 +45,159 @@
--color-error: hsl(2 72% 48%);
--color-error-soft: hsl(2 72% 90%);
--font-sans: "Avenir Next", "Segoe UI", "Noto Sans", system-ui, sans-serif;
/* Typography */
--font-sans: "Inter", "Avenir Next", "Segoe UI", "Noto Sans", system-ui, sans-serif;
--font-display: "Baskerville", "Times New Roman", serif;
/* Border Radius */
--radius: 0.75rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Shadows */
--shadow-soft: 0 1px 2px 0 rgb(23 32 46 / 0.06);
--shadow-card: 0 12px 30px -12px rgb(23 32 46 / 0.22);
--shadow-elevation-1: 0 1px 2px 0 rgb(23 32 46 / 0.06);
--shadow-elevation-2: 0 8px 24px -8px rgb(23 32 46 / 0.18);
/* Animation Timing */
--duration-fast: 120ms;
--duration-base: 200ms;
--duration-slow: 320ms;
--ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Dark Theme */
.dark {
--color-background: hsl(222 35% 10%);
--color-foreground: hsl(38 20% 92%);
--color-card: hsl(221 31% 13%);
--color-line: hsl(219 18% 25%);
--color-line-strong: hsl(219 18% 33%);
--color-card-foreground: hsl(38 20% 92%);
--color-popover: hsl(221 31% 13%);
--color-popover-foreground: hsl(38 20% 92%);
--color-primary: hsl(194 76% 62%);
--color-primary-foreground: hsl(222 35% 10%);
--color-primary-soft: hsl(210 34% 24%);
--color-muted: hsl(218 17% 72%);
--color-secondary: hsl(221 22% 20%);
--color-secondary-foreground: hsl(38 20% 92%);
--color-muted: hsl(220 17% 24%);
--color-muted-foreground: hsl(218 17% 72%);
--color-accent: hsl(210 34% 24%);
--color-accent-foreground: hsl(38 20% 92%);
--color-destructive: hsl(2 80% 65%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(219 18% 25%);
--color-input: hsl(219 18% 25%);
--color-ring: hsl(194 76% 62%);
--color-success: hsl(142 70% 55%);
--color-success-soft: hsl(142 30% 20%);
--color-warning: hsl(45 90% 55%);
--color-warning-soft: hsl(45 30% 20%);
--color-error: hsl(2 80% 65%);
--color-error-soft: hsl(2 30% 20%);
--shadow-soft: 0 1px 2px 0 rgb(2 8 18 / 0.35);
--shadow-card: 0 12px 30px -12px rgb(2 8 18 / 0.55);
--shadow-elevation-1: 0 1px 2px 0 rgb(2 8 18 / 0.35);
--shadow-elevation-2: 0 12px 30px -12px rgb(2 8 18 / 0.55);
}
/* Base styles */
/* Base Styles */
* {
border-color: var(--color-line);
border-color: var(--color-border);
}
html {
scroll-behavior: smooth;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
position: relative;
min-height: 100vh;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
/* Fond décoratif avec dégradés marqués */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: -1;
background:
radial-gradient(ellipse 100% 60% at 50% -10%, hsl(198 78% 37% / 0.35), transparent 60%),
radial-gradient(ellipse 80% 50% at 90% 100%, hsl(142 60% 45% / 0.25), transparent 50%),
radial-gradient(ellipse 70% 40% at 10% 90%, hsl(45 93% 47% / 0.2), transparent 50%),
radial-gradient(ellipse 60% 30% at 80% 50%, hsl(280 60% 50% / 0.12), transparent 50%);
background-attachment: fixed;
}
::-webkit-scrollbar-track {
background: transparent;
/* Dark mode - fond plus profond et marqué */
.dark body::before {
background:
radial-gradient(ellipse 100% 60% at 50% -10%, hsl(194 76% 62% / 0.3), transparent 60%),
radial-gradient(ellipse 80% 50% at 90% 100%, hsl(142 70% 55% / 0.22), transparent 50%),
radial-gradient(ellipse 70% 40% at 10% 90%, hsl(45 90% 55% / 0.18), transparent 50%),
radial-gradient(ellipse 60% 30% at 80% 50%, hsl(280 60% 65% / 0.1), transparent 50%);
}
::-webkit-scrollbar-thumb {
background: var(--color-line);
border-radius: 4px;
/* Cercles décoratifs floutés plus visibles */
body::after {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: -2;
background:
radial-gradient(circle 500px at 5% 15%, hsl(198 78% 37% / 0.15) 0%, transparent 70%),
radial-gradient(circle 400px at 95% 85%, hsl(142 60% 45% / 0.12) 0%, transparent 70%),
radial-gradient(circle 350px at 25% 95%, hsl(45 93% 47% / 0.1) 0%, transparent 70%),
radial-gradient(circle 450px at 85% 5%, hsl(198 78% 37% / 0.12) 0%, transparent 70%),
radial-gradient(circle 300px at 50% 50%, hsl(280 60% 50% / 0.08) 0%, transparent 70%);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-line-strong);
.dark body::after {
background:
radial-gradient(circle 500px at 5% 15%, hsl(194 76% 62% / 0.12) 0%, transparent 70%),
radial-gradient(circle 400px at 95% 85%, hsl(142 70% 55% / 0.1) 0%, transparent 70%),
radial-gradient(circle 350px at 25% 95%, hsl(45 90% 55% / 0.08) 0%, transparent 70%),
radial-gradient(circle 450px at 85% 5%, hsl(194 76% 62% / 0.1) 0%, transparent 70%),
radial-gradient(circle 300px at 50% 50%, hsl(280 60% 65% / 0.06) 0%, transparent 70%);
}
/* Texture subtile de grain */
.bg-grain {
position: relative;
}
.bg-grain::before {
content: '';
position: fixed;
top: -50%;
left: -50%;
right: -50%;
bottom: -50%;
width: 200%;
height: 200%;
pointer-events: none;
z-index: -1;
opacity: 0.06;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
.dark .bg-grain::before {
opacity: 0.04;
}
/* Selection */
@@ -75,40 +206,54 @@ body {
color: var(--color-foreground);
}
/* Focus visible */
.dark ::selection {
background: hsl(194 76% 62% / 0.2);
}
/* Focus Visible */
:focus-visible {
outline: 2px solid var(--color-primary);
outline: 2px solid var(--color-ring);
outline-offset: 2px;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
/* Animations */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.animate-fade-in {
animation: fade-in 0.3s ease-in;
}
/* Custom utilities - use directly in components */
.shadow-soft {
box-shadow: var(--shadow-soft);
/* Line clamp utilities */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.shadow-card {
box-shadow: var(--shadow-card);
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Dark mode overrides */
.dark {
--shadow-soft: 0 1px 2px 0 rgb(2 8 18 / 0.35);
--shadow-card: 0 12px 30px -12px rgb(2 8 18 / 0.55);
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Hide scrollbar */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

View File

@@ -1,7 +1,10 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { apiFetch } from "../../../lib/api";
import { Card, CardHeader, StatusBadge, JobTypeBadge, StatBox, ProgressBar } from "../../components/ui";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
StatusBadge, JobTypeBadge, StatBox, ProgressBar
} from "../../components/ui";
interface JobDetailPageProps {
params: Promise<{ id: string }>;
@@ -85,8 +88,14 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
return (
<>
<div className="mb-6">
<Link href="/jobs" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
Back to jobs
<Link
href="/jobs"
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>
Back to jobs
</Link>
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
</div>
@@ -94,43 +103,47 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Overview Card */}
<Card>
<CardHeader title="Overview" />
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">ID</span>
<code className="px-2 py-1 bg-muted/10 rounded font-mono text-sm text-foreground">{job.id}</code>
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-border/60">
<span className="text-sm text-muted-foreground">ID</span>
<code className="px-2 py-1 bg-muted rounded font-mono text-sm text-foreground">{job.id}</code>
</div>
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Type</span>
<div className="flex items-center justify-between py-2 border-b border-border/60">
<span className="text-sm text-muted-foreground">Type</span>
<JobTypeBadge type={job.type} />
</div>
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Status</span>
<div className="flex items-center justify-between py-2 border-b border-border/60">
<span className="text-sm text-muted-foreground">Status</span>
<StatusBadge status={job.status} />
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted">Library</span>
<span className="text-sm text-muted-foreground">Library</span>
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
</div>
</div>
</CardContent>
</Card>
{/* Timeline Card */}
<Card>
<CardHeader title="Timeline" />
<div className="space-y-4">
<CardHeader>
<CardTitle>Timeline</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-4">
<div className={`w-2 h-2 rounded-full mt-2 ${job.created_at ? 'bg-success' : 'bg-muted'}`} />
<div className="flex-1">
<span className="text-sm font-medium text-foreground">Created</span>
<p className="text-sm text-muted">{new Date(job.created_at).toLocaleString()}</p>
<p className="text-sm text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className={`w-2 h-2 rounded-full mt-2 ${job.started_at ? 'bg-success' : job.created_at ? 'bg-warning' : 'bg-muted'}`} />
<div className="flex-1">
<span className="text-sm font-medium text-foreground">Started</span>
<p className="text-sm text-muted">
<p className="text-sm text-muted-foreground">
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
</p>
</div>
@@ -139,7 +152,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
<div className={`w-2 h-2 rounded-full mt-2 ${job.finished_at ? 'bg-success' : job.started_at ? 'bg-primary animate-pulse' : 'bg-muted'}`} />
<div className="flex-1">
<span className="text-sm font-medium text-foreground">Finished</span>
<p className="text-sm text-muted">
<p className="text-sm text-muted-foreground">
{job.finished_at
? new Date(job.finished_at).toLocaleString()
: job.started_at
@@ -149,77 +162,93 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
</p>
</div>
</div>
</div>
{job.started_at && (
<div className="mt-4 inline-flex items-center px-3 py-1.5 bg-primary-soft text-primary rounded-lg text-sm font-medium">
Duration: {formatDuration(job.started_at, job.finished_at)}
</div>
)}
{job.started_at && (
<div className="mt-4 inline-flex items-center px-3 py-1.5 bg-primary/10 text-primary rounded-lg text-sm font-medium">
Duration: {formatDuration(job.started_at, job.finished_at)}
</div>
)}
</CardContent>
</Card>
{/* Progress Card */}
{(job.status === "running" || job.status === "success" || job.status === "failed") && (
<Card>
<CardHeader title="Progress" />
{job.total_files && job.total_files > 0 && (
<>
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
<div className="grid grid-cols-3 gap-4">
<StatBox value={job.processed_files || 0} label="Processed" variant="primary" />
<StatBox value={job.total_files} label="Total" />
<StatBox value={job.total_files - (job.processed_files || 0)} label="Remaining" variant="warning" />
<CardHeader>
<CardTitle>Progress</CardTitle>
</CardHeader>
<CardContent>
{job.total_files && job.total_files > 0 && (
<>
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
<div className="grid grid-cols-3 gap-4">
<StatBox value={job.processed_files || 0} label="Processed" variant="primary" />
<StatBox value={job.total_files} label="Total" />
<StatBox value={job.total_files - (job.processed_files || 0)} label="Remaining" variant="warning" />
</div>
</>
)}
{job.current_file && (
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
<span className="text-sm text-muted-foreground">Current file:</span>
<code className="block mt-1 text-xs font-mono text-foreground truncate">{job.current_file}</code>
</div>
</>
)}
{job.current_file && (
<div className="mt-4 p-3 bg-muted/5 rounded-lg">
<span className="text-sm text-muted">Current file:</span>
<code className="block mt-1 text-xs font-mono text-foreground truncate">{job.current_file}</code>
</div>
)}
)}
</CardContent>
</Card>
)}
{/* Statistics Card */}
{job.stats_json && (
<Card>
<CardHeader title="Statistics" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
</div>
{job.started_at && (
<div className="flex items-center justify-between py-2 border-t border-line">
<span className="text-sm text-muted">Speed:</span>
<span className="text-sm font-medium text-foreground">{formatSpeed(job.stats_json, duration)}</span>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
</div>
)}
{job.started_at && (
<div className="flex items-center justify-between py-2 border-t border-border/60">
<span className="text-sm text-muted-foreground">Speed:</span>
<span className="text-sm font-medium text-foreground">{formatSpeed(job.stats_json, duration)}</span>
</div>
)}
</CardContent>
</Card>
)}
{/* Errors Card */}
{errors.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader title={`Errors (${errors.length})`} />
<div className="space-y-2 max-h-80 overflow-y-auto">
<CardHeader>
<CardTitle>Errors ({errors.length})</CardTitle>
<CardDescription>Errors encountered during job execution</CardDescription>
</CardHeader>
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
{errors.map((error) => (
<div key={error.id} className="p-3 bg-error-soft rounded-lg">
<code className="block text-sm font-mono text-error mb-1">{error.file_path}</code>
<p className="text-sm text-error/80">{error.error_message}</p>
<span className="text-xs text-muted">{new Date(error.created_at).toLocaleString()}</span>
<div key={error.id} className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
<code className="block text-sm font-mono text-destructive mb-1">{error.file_path}</code>
<p className="text-sm text-destructive/80">{error.error_message}</p>
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString()}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Error Message */}
{job.error_opt && (
<Card className="lg:col-span-2">
<CardHeader title="Error" />
<pre className="p-4 bg-error-soft rounded-lg text-sm text-error overflow-x-auto">{job.error_opt}</pre>
<CardHeader>
<CardTitle>Error</CardTitle>
<CardDescription>Job failed with error</CardDescription>
</CardHeader>
<CardContent>
<pre className="p-4 bg-destructive/10 rounded-lg text-sm text-destructive overflow-x-auto border border-destructive/20">{job.error_opt}</pre>
</CardContent>
</Card>
)}
</div>

View File

@@ -2,7 +2,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listJobs, fetchLibraries, rebuildIndex, IndexJobDto, LibraryDto } from "../../lib/api";
import { JobsList } from "../components/JobsList";
import { Card, CardHeader, Button, FormField, FormSelect, FormRow } from "../components/ui";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
export const dynamic = "force-dynamic";
@@ -33,43 +33,63 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
return (
<>
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
Index Jobs
</h1>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Index Jobs
</h1>
</div>
<Card className="mb-6">
<form action={triggerRebuild}>
<FormRow>
<FormField>
<FormSelect name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit">🔄 Queue Rebuild</Button>
</FormRow>
</form>
<CardHeader>
<CardTitle>Queue New Job</CardTitle>
<CardDescription>Select a library to rebuild or perform a full rebuild</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form action={triggerRebuild}>
<FormRow>
<FormField className="flex-1">
<FormSelect name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Queue Rebuild
</Button>
</FormRow>
</form>
<form action={triggerFullRebuild} className="mt-3">
<FormRow>
<FormField>
<FormSelect name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit" variant="warning">🔁 Full Rebuild</Button>
</FormRow>
</form>
<form action={triggerFullRebuild}>
<FormRow>
<FormField className="flex-1">
<FormSelect name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit" variant="warning">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Full Rebuild
</Button>
</FormRow>
</form>
</CardContent>
</Card>
<JobsList

View File

@@ -9,60 +9,73 @@ import { JobsIndicator } from "./components/JobsIndicator";
import { NavIcon } from "./components/ui";
export const metadata: Metadata = {
title: "Stripstream Backoffice",
description: "Backoffice administration for Stripstream Librarian"
title: "StripStream Backoffice",
description: "Backoffice administration for StripStream Librarian"
};
type NavItem = {
href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens";
label: string;
icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens";
};
const navItems: NavItem[] = [
{ href: "/", label: "Dashboard", icon: "dashboard" },
{ href: "/books", label: "Books", icon: "books" },
{ href: "/libraries", label: "Libraries", icon: "libraries" },
{ href: "/jobs", label: "Jobs", icon: "jobs" },
{ href: "/tokens", label: "Tokens", icon: "tokens" },
];
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen bg-background text-foreground font-sans antialiased">
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
<ThemeProvider>
{/* Navigation */}
<nav className="sticky top-0 z-50 w-full border-b border-line bg-card/80 backdrop-blur-md">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
{/* Header avec effet glassmorphism */}
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
{/* Brand */}
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
>
<Image
src="/logo.png"
alt="Stripstream"
alt="StripStream"
width={36}
height={36}
className="rounded-lg"
/>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight">StripStream</span>
<span className="text-sm text-muted font-medium">backoffice</span>
<span className="text-xl font-bold tracking-tight text-foreground">
StripStream
</span>
<span className="text-sm text-muted-foreground font-medium">
backoffice
</span>
</div>
</Link>
{/* Navigation Links */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-1">
<NavLink href="/">
<NavIcon name="dashboard" /> Dashboard
</NavLink>
<NavLink href="/books">
<NavIcon name="books" /> Books
</NavLink>
<NavLink href="/libraries">
<NavIcon name="libraries" /> Libraries
</NavLink>
<NavLink href="/jobs">
<NavIcon name="jobs" /> Jobs
</NavLink>
<NavLink href="/tokens">
<NavIcon name="tokens" /> Tokens
</NavLink>
{navItems.map((item) => (
<NavLink key={item.href} href={item.href}>
<NavIcon name={item.icon} />
<span className="ml-2">{item.label}</span>
</NavLink>
))}
</div>
<div className="flex items-center gap-3 pl-6 border-l border-line">
{/* Actions */}
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator />
<ThemeToggle />
</div>
</div>
</div>
</nav>
</nav>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
@@ -75,11 +88,21 @@ export default function RootLayout({ children }: { children: ReactNode }) {
}
// Navigation Link Component
function NavLink({ href, children }: { href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens"; children: React.ReactNode }) {
function NavLink({ href, children }: { href: NavItem["href"]; children: React.ReactNode }) {
return (
<Link
href={href}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-foreground/80 hover:text-foreground hover:bg-primary-soft transition-colors"
className="
flex items-center
px-3 py-2
rounded-lg
text-sm font-medium
text-muted-foreground
hover:text-foreground
hover:bg-accent
transition-colors duration-200
active:scale-[0.98]
"
>
{children}
</Link>

View File

@@ -1,7 +1,7 @@
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../components/BookCard";
import { Card, Badge, Button, CursorPagination } from "../../../components/ui";
import Link from "next/link";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
import { CursorPagination } from "../../../components/ui";
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
@@ -42,38 +42,22 @@ export default async function LibraryBooksPage({
const hasPrevPage = !!cursor;
return (
<>
<div className="mb-6">
<Link href="/libraries" className="text-sm text-muted hover:text-primary transition-colors"> Back to libraries</Link>
</div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3 mb-6">
<svg className="w-8 h-8 text-success" 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>
{library.name}
</h1>
<Card className="mb-6">
<div className="flex flex-wrap items-center gap-3 text-sm">
<code className="text-xs font-mono text-muted bg-muted/10 px-2 py-1 rounded">{library.root_path}</code>
<span className="text-muted">|</span>
<span className="text-foreground">{library.book_count} book{library.book_count !== 1 ? 's' : ''}</span>
<span className="text-muted">|</span>
<Badge variant={library.enabled ? "success" : "muted"}>
{library.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</Card>
<div className="flex items-center gap-4 mb-6">
<h2 className="text-xl font-semibold text-foreground">
{series ? `Books in "${seriesDisplayName}"` : "All Books"}
</h2>
{series && (
<Link href={`/libraries/${id}/books`} className="text-sm text-primary hover:text-primary/80">
View all
</Link>
)}
</div>
<div className="space-y-6">
<LibrarySubPageHeader
library={library}
title={series ? `Books in "${seriesDisplayName}"` : "All Books"}
icon={
<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>
}
iconColor="text-success"
filterInfo={series ? {
label: `Showing books from series "${seriesDisplayName}"`,
clearHref: `/libraries/${id}/books`,
clearLabel: "View all books"
} : undefined}
/>
{books.length > 0 ? (
<>
@@ -90,6 +74,6 @@ export default async function LibraryBooksPage({
) : (
<EmptyState message={series ? `No books in series "${seriesDisplayName}"` : "No books in this library yet"} />
)}
</>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto } f
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Card, Badge } from "../../../components/ui";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
export const dynamic = "force-dynamic";
@@ -23,29 +23,17 @@ export default async function LibrarySeriesPage({
}
return (
<>
<div className="mb-6">
<Link href="/libraries" className="text-sm text-muted hover:text-primary transition-colors"> Back to libraries</Link>
</div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3 mb-6">
<svg className="w-8 h-8 text-primary" 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>
{library.name}
</h1>
<Card className="mb-6">
<div className="flex flex-wrap items-center gap-3 text-sm">
<code className="text-xs font-mono text-muted bg-muted/10 px-2 py-1 rounded">{library.root_path}</code>
<span className="text-muted">|</span>
<span className="text-foreground">{library.book_count} book{library.book_count !== 1 ? 's' : ''}</span>
<span className="text-muted">|</span>
<Badge variant={library.enabled ? "success" : "muted"}>
{library.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</Card>
<h2 className="text-xl font-semibold text-foreground mb-6">Series ({series.length})</h2>
<div className="space-y-6">
<LibrarySubPageHeader
library={library}
title={`Series (${series.length})`}
icon={
<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>
}
iconColor="text-primary"
/>
{series.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
@@ -55,8 +43,8 @@ export default async function LibrarySeriesPage({
href={`/libraries/${id}/books?series=${encodeURIComponent(s.name)}`}
className="group"
>
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden hover:shadow-card transition-shadow">
<div className="aspect-[2/3] relative bg-muted/10">
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
<div className="aspect-[2/3] relative bg-muted/50">
<Image
src={getBookCoverUrl(s.first_book_id)}
alt={`Cover of ${s.name}`}
@@ -69,7 +57,7 @@ export default async function LibrarySeriesPage({
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
{s.name === "unclassified" ? "Unclassified" : s.name}
</h3>
<p className="text-xs text-muted mt-1">
<p className="text-xs text-muted-foreground mt-1">
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
</p>
</div>
@@ -78,10 +66,10 @@ export default async function LibrarySeriesPage({
))}
</div>
) : (
<div className="text-center py-12 text-muted">
<div className="text-center py-12 text-muted-foreground">
<p>No series found in this library</p>
</div>
)}
</>
</div>
);
}

View File

@@ -2,7 +2,10 @@ import { revalidatePath } from "next/cache";
import Link from "next/link";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api";
import { LibraryActions } from "../components/LibraryActions";
import { Card, CardHeader, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Badge, FormField, FormInput, FormSelect, FormRow
} from "../components/ui";
export const dynamic = "force-dynamic";
@@ -73,105 +76,130 @@ export default async function LibrariesPage() {
return (
<>
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
<svg className="w-8 h-8 text-primary" 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>
Libraries
</h1>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-primary" 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>
Libraries
</h1>
</div>
{/* Add Library Form */}
<Card className="mb-6">
<CardHeader title="Add New Library" />
<form action={addLibrary}>
<FormRow>
<FormField>
<FormInput name="name" placeholder="Library name" required />
</FormField>
<FormField>
<FormSelect name="root_path" required defaultValue="">
<option value="" disabled>Select folder...</option>
{folders.map((folder) => (
<option key={folder.path} value={folder.path}>
{folder.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit"> Add Library</Button>
</FormRow>
</form>
<CardHeader>
<CardTitle>Add New Library</CardTitle>
<CardDescription>Create a new library from an existing folder</CardDescription>
</CardHeader>
<CardContent>
<form action={addLibrary}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder="Library name" required />
</FormField>
<FormField className="flex-1 min-w-48">
<FormSelect name="root_path" required defaultValue="">
<option value="" disabled>Select folder...</option>
{folders.map((folder) => (
<option key={folder.path} value={folder.path}>
{folder.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit">Add Library</Button>
</FormRow>
</form>
</CardContent>
</Card>
{/* Libraries Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{libraries.map((lib) => {
const seriesCount = seriesCountMap.get(lib.id) || 0;
return (
<Card key={lib.id} className="flex flex-col">
{/* Header with settings */}
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-foreground">{lib.name}</h3>
{!lib.enabled && <Badge variant="muted" className="mt-1">Disabled</Badge>}
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{lib.name}</CardTitle>
{!lib.enabled && <Badge variant="muted" className="mt-1">Disabled</Badge>}
</div>
<LibraryActions
libraryId={lib.id}
monitorEnabled={lib.monitor_enabled}
scanMode={lib.scan_mode}
watcherEnabled={lib.watcher_enabled}
/>
</div>
<LibraryActions
libraryId={lib.id}
monitorEnabled={lib.monitor_enabled}
scanMode={lib.scan_mode}
watcherEnabled={lib.watcher_enabled}
/>
</div>
</CardHeader>
<CardContent className="flex-1 pt-0">
{/* Path */}
<code className="text-xs font-mono text-muted-foreground mb-4 break-all block">{lib.root_path}</code>
{/* Path */}
<code className="text-xs font-mono text-muted mb-4 break-all">{lib.root_path}</code>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 mb-4">
<Link
href={`/libraries/${lib.id}/books`}
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
>
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
<span className="text-xs text-muted-foreground">Books</span>
</Link>
<Link
href={`/libraries/${lib.id}/series`}
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
>
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
<span className="text-xs text-muted-foreground">Series</span>
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 mb-4">
<Link href={`/libraries/${lib.id}/books`} className="text-center p-3 bg-muted/5 rounded-lg hover:bg-muted/10 transition-colors">
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
<span className="text-xs text-muted">Books</span>
</Link>
<Link href={`/libraries/${lib.id}/series`} className="text-center p-3 bg-muted/5 rounded-lg hover:bg-muted/10 transition-colors">
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
<span className="text-xs text-muted">Series</span>
</Link>
</div>
{/* Status */}
<div className="flex items-center gap-3 mb-4 text-sm">
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted'}`}>
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manual'}
</span>
{lib.watcher_enabled && (
<span className="text-warning" title="File watcher active"></span>
)}
{lib.monitor_enabled && lib.next_scan_at && (
<span className="text-xs text-muted ml-auto">
Next: {formatNextScan(lib.next_scan_at)}
{/* Status */}
<div className="flex items-center gap-3 mb-4 text-sm">
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted-foreground'}`}>
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manual'}
</span>
)}
</div>
{lib.watcher_enabled && (
<span className="text-warning" title="File watcher active"></span>
)}
{lib.monitor_enabled && lib.next_scan_at && (
<span className="text-xs text-muted-foreground ml-auto">
Next: {formatNextScan(lib.next_scan_at)}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-auto">
<form className="flex-1">
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="primary" size="sm" className="w-full" formAction={scanLibraryAction}>
🔄 Index
</Button>
</form>
<form className="flex-1">
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="secondary" size="sm" className="w-full" formAction={scanLibraryFullAction}>
🔁 Full
</Button>
</form>
<form>
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="danger" size="sm" formAction={removeLibrary}>
🗑
</Button>
</form>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<form className="flex-1">
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="default" size="sm" className="w-full" formAction={scanLibraryAction}>
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Index
</Button>
</form>
<form className="flex-1">
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="secondary" size="sm" className="w-full" formAction={scanLibraryFullAction}>
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Full
</Button>
</form>
<form>
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="destructive" size="sm" formAction={removeLibrary}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</form>
</div>
</CardContent>
</Card>
);
})}

View File

@@ -1,76 +1,76 @@
export default function DashboardPage() {
return (
<div className="max-w-4xl mx-auto">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight mb-4">
Stripstream Backoffice
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">
StripStream Backoffice
</h1>
<p className="text-lg text-muted max-w-2xl mx-auto">
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Manage libraries, indexing jobs, and API tokens from a modern admin interface.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Libraries Card */}
<a
href="/libraries"
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
>
<div className="w-12 h-12 bg-primary-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-primary transition-colors">
<svg className="w-6 h-6 text-primary group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-primary transition-colors duration-200">
<svg className="w-6 h-6 text-primary group-hover:text-primary-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>
</div>
<h2 className="text-xl font-semibold mb-2">Libraries</h2>
<p className="text-muted text-sm">Manage your comic libraries and folders</p>
<h2 className="text-xl font-semibold mb-2 text-foreground">Libraries</h2>
<p className="text-muted-foreground text-sm leading-relaxed">Manage your comic libraries and folders</p>
</a>
{/* Books Card */}
<a
href="/books"
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
>
<div className="w-12 h-12 bg-success-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-success transition-colors">
<div className="w-12 h-12 bg-success/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-success transition-colors duration-200">
<svg className="w-6 h-6 text-success group-hover:text-white" 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>
</div>
<h2 className="text-xl font-semibold mb-2">Books</h2>
<p className="text-muted text-sm">Browse and search your comic collection</p>
<h2 className="text-xl font-semibold mb-2 text-foreground">Books</h2>
<p className="text-muted-foreground text-sm leading-relaxed">Browse and search your comic collection</p>
</a>
{/* Jobs Card */}
<a
href="/jobs"
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
>
<div className="w-12 h-12 bg-warning-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-warning transition-colors">
<div className="w-12 h-12 bg-warning/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-warning transition-colors duration-200">
<svg className="w-6 h-6 text-warning group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">Jobs</h2>
<p className="text-muted text-sm">Monitor indexing jobs and progress</p>
<h2 className="text-xl font-semibold mb-2 text-foreground">Jobs</h2>
<p className="text-muted-foreground text-sm leading-relaxed">Monitor indexing jobs and progress</p>
</a>
{/* Tokens Card */}
<a
href="/tokens"
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
>
<div className="w-12 h-12 bg-error-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-error transition-colors">
<svg className="w-6 h-6 text-error group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="w-12 h-12 bg-destructive/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-destructive transition-colors duration-200">
<svg className="w-6 h-6 text-destructive group-hover:text-destructive-foreground" 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>
</div>
<h2 className="text-xl font-semibold mb-2">Tokens</h2>
<p className="text-muted text-sm">Manage API authentication tokens</p>
<h2 className="text-xl font-semibold mb-2 text-foreground">Tokens</h2>
<p className="text-muted-foreground text-sm leading-relaxed">Manage API authentication tokens</p>
</a>
</div>
<div className="mt-12 p-6 bg-primary-soft rounded-xl border border-primary/20">
<div className="mt-12 p-6 bg-primary/5 backdrop-blur-sm rounded-xl border border-primary/20 hover:bg-primary/8 hover:border-primary/30 transition-all duration-300">
<h2 className="text-lg font-semibold mb-2 text-primary">Getting Started</h2>
<p className="text-muted">
<p className="text-muted-foreground leading-relaxed">
Start by creating a library from your comic folders, then trigger an index job to scan your collection.
</p>
</div>

View File

@@ -3,26 +3,127 @@
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
// Sun Icon
const SunIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" strokeWidth="2" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
);
// Moon Icon
const MoonIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
);
// Monitor Icon (for system)
const MonitorIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="14" rx="2" strokeWidth="2" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 21h8m-4-4v4" />
</svg>
);
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const { theme, setTheme, resolvedTheme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<button
type="button"
className="h-9 w-9 rounded-md flex items-center justify-center text-muted-foreground"
disabled
>
<SunIcon className="h-4 w-4" />
</button>
);
}
const activeTheme = theme === "system" ? resolvedTheme : theme;
const nextTheme = activeTheme === "dark" ? "light" : "dark";
const toggleTheme = () => {
if (theme === "system") {
setTheme(systemTheme === "dark" ? "light" : "dark");
} else {
setTheme(theme === "dark" ? "light" : "dark");
}
};
return (
<button
type="button"
className="theme-toggle"
onClick={() => setTheme(nextTheme)}
aria-label="Toggle color theme"
disabled={!mounted}
onClick={toggleTheme}
className="h-9 w-9 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent transition-colors duration-200"
title={activeTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
aria-label={activeTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{mounted ? (activeTheme === "dark" ? "Dark" : "Light") : "Theme"}
<div className="relative h-4 w-4">
<SunIcon
className={`absolute inset-0 h-4 w-4 transition-all duration-300 rotate-0 scale-100 ${
activeTheme === "dark" ? "rotate-90 scale-0 opacity-0" : "rotate-0 scale-100 opacity-100"
}`}
/>
<MoonIcon
className={`absolute inset-0 h-4 w-4 transition-all duration-300 -rotate-90 scale-0 ${
activeTheme === "dark" ? "rotate-0 scale-100 opacity-100" : "-rotate-90 scale-0 opacity-0"
}`}
/>
</div>
</button>
);
}
// Full theme selector with dropdown
export function ThemeSelector() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<SunIcon className="h-4 w-4" />
<span>Theme</span>
</div>
);
}
const themes = [
{ value: "light", label: "Light", icon: SunIcon },
{ value: "dark", label: "Dark", icon: MoonIcon },
{ value: "system", label: "System", icon: MonitorIcon },
];
return (
<div className="flex items-center gap-1 rounded-lg border border-border bg-background p-1">
{themes.map(({ value, label, icon: Icon }) => (
<button
key={value}
onClick={() => setTheme(value)}
className={`
flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-sm font-medium
transition-colors duration-200
${theme === value
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
}
`}
title={label}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listTokens, createToken, revokeToken, TokenDto } from "../../lib/api";
import { Card, CardHeader, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
export const dynamic = "force-dynamic";
@@ -33,68 +33,90 @@ export default async function TokensPage({
return (
<>
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
<svg className="w-8 h-8 text-error" 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>
API Tokens
</h1>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-destructive" 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>
API Tokens
</h1>
</div>
{params.created ? (
<Card className="mb-6">
<strong className="text-foreground block mb-2">Token created (copy it now, it won't be shown again):</strong>
<pre className="p-4 bg-muted/10 rounded-lg text-sm font-mono text-foreground overflow-x-auto">{params.created}</pre>
<Card className="mb-6 border-success/50 bg-success/5">
<CardHeader>
<CardTitle className="text-success">Token Created</CardTitle>
<CardDescription>Copy it now, it won't be shown again</CardDescription>
</CardHeader>
<CardContent>
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
</CardContent>
</Card>
) : null}
<Card className="mb-6">
<form action={createTokenAction}>
<FormRow>
<FormField>
<FormInput name="name" placeholder="token name" required />
</FormField>
<FormField>
<FormSelect name="scope" defaultValue="read">
<option value="read">read</option>
<option value="admin">admin</option>
</FormSelect>
</FormField>
<Button type="submit"> Create Token</Button>
</FormRow>
</form>
<CardHeader>
<CardTitle>Create New Token</CardTitle>
<CardDescription>Generate a new API token with the desired scope</CardDescription>
</CardHeader>
<CardContent>
<form action={createTokenAction}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder="Token name" required />
</FormField>
<FormField className="w-32">
<FormSelect name="scope" defaultValue="read">
<option value="read">Read</option>
<option value="admin">Admin</option>
</FormSelect>
</FormField>
<Button type="submit">Create Token</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="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">Name</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Scope</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Prefix</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Revoked</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">Name</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Scope</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Prefix</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">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
<tbody className="divide-y divide-border/60">
{tokens.map((token) => (
<tr key={token.id} className="hover:bg-muted/5">
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
<td className="px-4 py-3 text-sm text-foreground">{token.scope}</td>
<td className="px-4 py-3 text-sm">
<code className="px-2 py-1 bg-muted/10 rounded font-mono text-foreground">{token.prefix}</code>
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
{token.scope}
</Badge>
</td>
<td className="px-4 py-3 text-sm">
<code className="px-2 py-1 bg-muted rounded font-mono text-foreground">{token.prefix}</code>
</td>
<td className="px-4 py-3 text-sm">
{token.revoked_at ? (
<span className="inline-flex px-2 py-1 rounded-full text-xs font-semibold bg-error-soft text-error">yes</span>
<Badge variant="error">Revoked</Badge>
) : (
<span className="inline-flex px-2 py-1 rounded-full text-xs font-semibold bg-success-soft text-success">no</span>
<Badge variant="success">Active</Badge>
)}
</td>
<td className="px-4 py-3">
{!token.revoked_at && (
<form action={revokeTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="danger" size="sm">
🚫 Revoke
<Button type="submit" variant="destructive" size="sm">
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Revoke
</Button>
</form>
)}

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -14,6 +14,7 @@
"react-dom": "19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "22.13.14",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.5",
@@ -23,6 +24,19 @@
"typescript": "5.8.2"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -547,6 +561,56 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
@@ -702,6 +766,289 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
"integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.31.1",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.1"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
"integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.1",
"@tailwindcss/oxide-darwin-arm64": "4.2.1",
"@tailwindcss/oxide-darwin-x64": "4.2.1",
"@tailwindcss/oxide-freebsd-x64": "4.2.1",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
"@tailwindcss/oxide-linux-x64-musl": "4.2.1",
"@tailwindcss/oxide-wasm32-wasi": "4.2.1",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
"integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
"integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
"integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
"integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
"integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
"integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
"integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
"integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
"integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
"integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.8.1",
"@emnapi/runtime": "^1.8.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.1",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
"integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
"integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz",
"integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.2.1",
"@tailwindcss/oxide": "4.2.1",
"postcss": "^8.5.6",
"tailwindcss": "4.2.1"
}
},
"node_modules/@types/node": {
"version": "22.13.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
@@ -852,8 +1199,8 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -865,6 +1212,20 @@
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -889,6 +1250,306 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.31.1",
"lightningcss-darwin-arm64": "1.31.1",
"lightningcss-darwin-x64": "1.31.1",
"lightningcss-freebsd-x64": "1.31.1",
"lightningcss-linux-arm-gnueabihf": "1.31.1",
"lightningcss-linux-arm64-gnu": "1.31.1",
"lightningcss-linux-arm64-musl": "1.31.1",
"lightningcss-linux-x64-gnu": "1.31.1",
"lightningcss-linux-x64-musl": "1.31.1",
"lightningcss-win32-arm64-msvc": "1.31.1",
"lightningcss-win32-x64-msvc": "1.31.1"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1171,6 +1832,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -14,6 +14,7 @@
"react-dom": "19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "22.13.14",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.5",

View File

@@ -4,12 +4,10 @@ WORKDIR /app
COPY Cargo.toml ./
COPY apps/api/Cargo.toml apps/api/Cargo.toml
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
COPY crates/core/Cargo.toml crates/core/Cargo.toml
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
COPY apps/api/src apps/api/src
COPY apps/indexer/src apps/indexer/src
COPY apps/admin-ui/src apps/admin-ui/src
COPY crates/core/src crates/core/src
COPY crates/parsers/src crates/parsers/src