feat(ui): Components refactoring with Tailwind - UI kit, icons, lazy loading images
- Created reusable UI components (Card, Button, Badge, Form, Icon) - Added PageIcon and NavIcon components with consistent styling - Refactored all pages to use new UI components - Added non-blocking image loading with skeleton for book covers - Created LibraryActions dropdown for library settings - Added emojis to buttons for better UX - Fixed Client Component issues with getBookCoverUrl
This commit is contained in:
@@ -32,113 +32,128 @@ export default async function BookDetailPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="breadcrumb">
|
||||
<Link href="/books">← Back to books</Link>
|
||||
<div className="mb-6">
|
||||
<Link href="/books" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
|
||||
← Back to books
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="book-detail">
|
||||
<div className="book-detail-cover">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.id)}
|
||||
alt={`Cover of ${book.title}`}
|
||||
width={300}
|
||||
height={440}
|
||||
className="detail-cover-image"
|
||||
unoptimized
|
||||
loading="lazy"
|
||||
/>
|
||||
<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">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.id)}
|
||||
alt={`Cover of ${book.title}`}
|
||||
width={300}
|
||||
height={440}
|
||||
className="w-auto h-auto max-w-[300px] rounded-lg"
|
||||
unoptimized
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="book-detail-info">
|
||||
<h1>{book.title}</h1>
|
||||
|
||||
{book.author && (
|
||||
<p className="detail-author">by {book.author}</p>
|
||||
)}
|
||||
|
||||
{book.series && (
|
||||
<p className="detail-series">
|
||||
{book.series}
|
||||
{book.volume && <span className="volume">Volume {book.volume}</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="detail-meta">
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Format:</span>
|
||||
<span className={`book-kind ${book.kind}`}>{book.kind.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-card rounded-xl shadow-soft border border-line p-6">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">{book.title}</h1>
|
||||
|
||||
{book.volume && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Volume:</span>
|
||||
<span>{book.volume}</span>
|
||||
</div>
|
||||
{book.author && (
|
||||
<p className="text-lg text-muted mb-4">by {book.author}</p>
|
||||
)}
|
||||
|
||||
{book.language && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Language:</span>
|
||||
<span>{book.language.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.page_count && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Pages:</span>
|
||||
<span>{book.page_count}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Library:</span>
|
||||
<span>{library?.name || book.library_id}</span>
|
||||
</div>
|
||||
|
||||
{book.series && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Series:</span>
|
||||
<span>{book.series}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted 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>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{book.file_format && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">File Format:</span>
|
||||
<span>{book.file_format.toUpperCase()}</span>
|
||||
<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>
|
||||
<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.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>
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-sm text-foreground">{book.page_count}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.file_parse_status && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Parse Status:</span>
|
||||
<span className={`status-${book.file_parse_status}`}>{book.file_parse_status}</span>
|
||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
||||
<span className="text-sm text-muted">Library:</span>
|
||||
<span className="text-sm text-foreground">{library?.name || book.library_id}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.file_path && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">File Path:</span>
|
||||
<code className="file-path">{book.file_path}</code>
|
||||
{book.series && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
||||
<span className="text-sm text-muted">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>
|
||||
<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>
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<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>
|
||||
<code className="text-xs font-mono text-foreground break-all">{book.id}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Book ID:</span>
|
||||
<code className="book-id">{book.id}</code>
|
||||
<div className="flex flex-col py-2 border-b border-line">
|
||||
<span className="text-sm text-muted 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-foreground">{new Date(book.updated_at).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Library ID:</span>
|
||||
<code className="book-id">{book.library_id}</code>
|
||||
</div>
|
||||
|
||||
{book.updated_at && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Updated:</span>
|
||||
<span>{new Date(book.updated_at).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +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 } from "../components/ui";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -50,39 +51,55 @@ export default async function BooksPage({
|
||||
nextCursor = booksPage.next_cursor;
|
||||
}
|
||||
|
||||
const displayBooks = searchResults || books;
|
||||
const displayBooks = (searchResults || books).map(book => ({
|
||||
...book,
|
||||
coverUrl: getBookCoverUrl(book.id)
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Books</h1>
|
||||
<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>
|
||||
|
||||
{/* Filtres et recherche */}
|
||||
<div className="card">
|
||||
<form className="search-form">
|
||||
<input
|
||||
name="q"
|
||||
placeholder="Search books..."
|
||||
defaultValue={searchQuery}
|
||||
className="search-input"
|
||||
/>
|
||||
<select name="library" defaultValue={libraryId || ""}>
|
||||
<option value="">All libraries</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">Search</button>
|
||||
{searchQuery && (
|
||||
<Link href="/books" className="button secondary">Clear</Link>
|
||||
)}
|
||||
<Card className="mb-6">
|
||||
<form>
|
||||
<FormRow>
|
||||
<FormField>
|
||||
<FormInput
|
||||
name="q"
|
||||
placeholder="Search books..."
|
||||
defaultValue={searchQuery}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<FormSelect name="library" defaultValue={libraryId || ""}>
|
||||
<option value="">All libraries</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
{/* Résultats de recherche */}
|
||||
{searchQuery && totalHits !== null && (
|
||||
<p className="results-info">
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
||||
</p>
|
||||
)}
|
||||
@@ -90,15 +107,20 @@ export default async function BooksPage({
|
||||
{/* Grille de livres */}
|
||||
{displayBooks.length > 0 ? (
|
||||
<>
|
||||
<BooksGrid books={displayBooks} getBookCoverUrl={getBookCoverUrl} />
|
||||
<BooksGrid books={displayBooks} />
|
||||
|
||||
{/* Pagination */}
|
||||
{!searchQuery && nextCursor && (
|
||||
<div className="pagination">
|
||||
<div className="flex justify-center mt-8">
|
||||
<form>
|
||||
<input type="hidden" name="library" value={libraryId || ""} />
|
||||
<input type="hidden" name="cursor" value={nextCursor} />
|
||||
<button type="submit">Load more</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
📥 Load more
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,42 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BookDto } from "../../lib/api";
|
||||
|
||||
interface BookCardProps {
|
||||
book: BookDto;
|
||||
getBookCoverUrl: (bookId: string) => string;
|
||||
book: BookDto & { coverUrl?: string };
|
||||
}
|
||||
|
||||
export function BookCard({ book, getBookCoverUrl }: BookCardProps) {
|
||||
function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<Link href={`/books/${book.id}`} className="book-card">
|
||||
<div className="book-cover">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.id)}
|
||||
alt={`Cover of ${book.title}`}
|
||||
width={150}
|
||||
height={220}
|
||||
className="cover-image"
|
||||
unoptimized
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="relative aspect-[2/3] overflow-hidden bg-gradient-to-br from-line/50 to-line">
|
||||
{/* Skeleton */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-muted/10 animate-pulse transition-opacity duration-300 ${
|
||||
isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/20 to-transparent shimmer" />
|
||||
</div>
|
||||
<div className="book-info">
|
||||
<h3 className="book-title" title={book.title}>
|
||||
|
||||
{/* Image */}
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
className={`object-cover group-hover:scale-105 transition-all duration-300 ${
|
||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookCard({ book }: BookCardProps) {
|
||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/pages/1?format=webp&width=200`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/${book.id}`}
|
||||
className="group block bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all duration-200 overflow-hidden"
|
||||
>
|
||||
<BookImage
|
||||
src={coverUrl}
|
||||
alt={`Cover of ${book.title}`}
|
||||
/>
|
||||
|
||||
{/* Book Info */}
|
||||
<div className="p-4">
|
||||
<h3
|
||||
className="font-semibold text-foreground mb-1 line-clamp-2 min-h-[2.5rem]"
|
||||
title={book.title}
|
||||
>
|
||||
{book.title}
|
||||
</h3>
|
||||
|
||||
{book.author && (
|
||||
<p className="book-author">{book.author}</p>
|
||||
<p className="text-sm text-muted mb-1 truncate">{book.author}</p>
|
||||
)}
|
||||
|
||||
{book.series && (
|
||||
<p className="book-series">
|
||||
<p className="text-xs text-muted/80 truncate mb-2">
|
||||
{book.series}
|
||||
{book.volume && ` #${book.volume}`}
|
||||
{book.volume && <span className="text-primary font-medium"> #{book.volume}</span>}
|
||||
</p>
|
||||
)}
|
||||
<div className="book-meta">
|
||||
<span className={`book-kind ${book.kind}`}>{book.kind.toUpperCase()}</span>
|
||||
{book.language && <span className="book-lang">{book.language.toUpperCase()}</span>}
|
||||
|
||||
{/* Meta Tags */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`
|
||||
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
|
||||
${book.kind === 'cbz' ? 'bg-success-soft text-success' : ''}
|
||||
${book.kind === 'cbr' ? 'bg-warning-soft text-warning' : ''}
|
||||
${book.kind === 'pdf' ? 'bg-error-soft text-error' : ''}
|
||||
`}>
|
||||
{book.kind}
|
||||
</span>
|
||||
{book.language && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary-soft text-primary">
|
||||
{book.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -44,15 +94,14 @@ export function BookCard({ book, getBookCoverUrl }: BookCardProps) {
|
||||
}
|
||||
|
||||
interface BooksGridProps {
|
||||
books: BookDto[];
|
||||
getBookCoverUrl: (bookId: string) => string;
|
||||
books: (BookDto & { coverUrl?: string })[];
|
||||
}
|
||||
|
||||
export function BooksGrid({ books, getBookCoverUrl }: BooksGridProps) {
|
||||
export function BooksGrid({ books }: BooksGridProps) {
|
||||
return (
|
||||
<div className="books-grid">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} getBookCoverUrl={getBookCoverUrl} />
|
||||
<BookCard key={book.id} book={book} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -64,8 +113,13 @@ interface EmptyStateProps {
|
||||
|
||||
export function EmptyState({ message }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>{message}</p>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 mb-4 text-muted/30">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-muted text-lg">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { StatusBadge, Badge, ProgressBar } from "./ui";
|
||||
|
||||
interface ProgressEvent {
|
||||
job_id: string;
|
||||
@@ -28,7 +29,6 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Use SSE via local proxy
|
||||
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
@@ -69,11 +69,19 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
}, [jobId, onComplete]);
|
||||
|
||||
if (error) {
|
||||
return <div className="progress-error">Error: {error}</div>;
|
||||
return (
|
||||
<div className="p-4 bg-error-soft text-error rounded-lg text-sm">
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!progress) {
|
||||
return <div className="progress-loading">Loading progress...</div>;
|
||||
return (
|
||||
<div className="p-4 text-muted text-sm">
|
||||
Loading progress...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const percent = progress.progress_percent ?? 0;
|
||||
@@ -81,26 +89,20 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
const total = progress.total_files ?? 0;
|
||||
|
||||
return (
|
||||
<div className="job-progress">
|
||||
<div className="progress-header">
|
||||
<span className={`status-badge status-${progress.status}`}>
|
||||
{progress.status}
|
||||
</span>
|
||||
{isComplete && <span className="complete-badge">Complete</span>}
|
||||
<div className="p-4 bg-card rounded-lg border border-line">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<StatusBadge status={progress.status} />
|
||||
{isComplete && (
|
||||
<Badge variant="success">Complete</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
<span className="progress-percent">{percent}%</span>
|
||||
</div>
|
||||
<ProgressBar value={percent} showLabel size="md" className="mb-3" />
|
||||
|
||||
<div className="progress-stats">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted mb-3">
|
||||
<span>{processed} / {total} files</span>
|
||||
{progress.current_file && (
|
||||
<span className="current-file" title={progress.current_file}>
|
||||
<span className="truncate max-w-md" title={progress.current_file}>
|
||||
Current: {progress.current_file.length > 40
|
||||
? progress.current_file.substring(0, 40) + "..."
|
||||
: progress.current_file}
|
||||
@@ -109,12 +111,12 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
</div>
|
||||
|
||||
{progress.stats_json && (
|
||||
<div className="progress-detailed-stats">
|
||||
<span>Scanned: {progress.stats_json.scanned_files}</span>
|
||||
<span>Indexed: {progress.stats_json.indexed_files}</span>
|
||||
<span>Removed: {progress.stats_json.removed_files}</span>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
|
||||
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
|
||||
<Badge variant="warning">Removed: {progress.stats_json.removed_files}</Badge>
|
||||
{progress.stats_json.errors > 0 && (
|
||||
<span className="error-count">Errors: {progress.stats_json.errors}</span>
|
||||
<Badge variant="error">Errors: {progress.stats_json.errors}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { JobProgress } from "./JobProgress";
|
||||
import { StatusBadge, Button } from "./ui";
|
||||
|
||||
interface JobRowProps {
|
||||
job: {
|
||||
@@ -25,52 +26,71 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
||||
|
||||
const handleComplete = () => {
|
||||
setShowProgress(false);
|
||||
// Trigger a page refresh to update the job status
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={highlighted ? "job-highlighted" : undefined}>
|
||||
<td>
|
||||
<Link href={`/jobs/${job.id}`} className="job-id-link">
|
||||
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/jobs/${job.id}`}
|
||||
className="text-primary hover:text-primary/80 hover:underline font-mono text-sm"
|
||||
>
|
||||
<code>{job.id.slice(0, 8)}</code>
|
||||
</Link>
|
||||
</td>
|
||||
<td>{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}</td>
|
||||
<td>{job.type}</td>
|
||||
<td>
|
||||
<span className={`status-${job.status}`}>{job.status}</span>
|
||||
{job.error_opt && <span className="error-hint" title={job.error_opt}>!</span>}
|
||||
{(job.status === "running" || job.status === "pending") && (
|
||||
<button
|
||||
className="toggle-progress-btn"
|
||||
onClick={() => setShowProgress(!showProgress)}
|
||||
>
|
||||
{showProgress ? "Hide" : "Show"} progress
|
||||
</button>
|
||||
)}
|
||||
<td className="px-4 py-3 text-sm text-foreground">
|
||||
{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}
|
||||
</td>
|
||||
<td>{new Date(job.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<Link href={`/jobs/${job.id}`} className="view-btn">
|
||||
<td className="px-4 py-3 text-sm text-foreground">{job.type}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<StatusBadge status={job.status} />
|
||||
{job.error_opt && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-error text-white text-xs font-bold cursor-help"
|
||||
title={job.error_opt}
|
||||
>
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
{(job.status === "running" || job.status === "pending") && (
|
||||
<button
|
||||
className="text-xs text-primary hover:text-primary/80 hover:underline"
|
||||
onClick={() => setShowProgress(!showProgress)}
|
||||
>
|
||||
{showProgress ? "Hide" : "Show"} progress
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted">
|
||||
{new Date(job.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/jobs/${job.id}`}
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
{(job.status === "pending" || job.status === "running") && (
|
||||
<button
|
||||
className="cancel-btn"
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onCancel(job.id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{showProgress && (job.status === "running" || job.status === "pending") && (
|
||||
<tr className="progress-row">
|
||||
<td colSpan={6}>
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-3 bg-muted/5">
|
||||
<JobProgress
|
||||
jobId={job.id}
|
||||
onComplete={handleComplete}
|
||||
|
||||
@@ -22,7 +22,6 @@ interface Job {
|
||||
export function JobsIndicator() {
|
||||
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,7 +65,11 @@ export function JobsIndicator() {
|
||||
|
||||
if (totalCount === 0) {
|
||||
return (
|
||||
<Link href="/jobs" className="jobs-indicator-empty" title="View all jobs">
|
||||
<Link
|
||||
href="/jobs"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted transition-all duration-200 hover:text-foreground hover:bg-primary-soft"
|
||||
title="View all jobs"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="3" width="20" height="18" rx="2" />
|
||||
<path d="M6 8h12M6 12h12M6 16h8" />
|
||||
@@ -76,15 +79,19 @@ export function JobsIndicator() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="jobs-indicator-wrapper" ref={dropdownRef}>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
className={`jobs-indicator-button ${runningJobs.length > 0 ? 'has-running' : ''} ${isOpen ? 'open' : ''}`}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-all duration-200 ${
|
||||
runningJobs.length > 0
|
||||
? 'bg-success-soft text-success'
|
||||
: 'bg-warning-soft text-warning'
|
||||
} ${isOpen ? 'ring-2 ring-primary' : ''}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{/* Animated spinner for running jobs */}
|
||||
{runningJobs.length > 0 && (
|
||||
<div className="jobs-spinner">
|
||||
<div className="w-4 h-4 animate-spin">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
||||
@@ -93,21 +100,19 @@ export function JobsIndicator() {
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<svg className="jobs-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="3" width="20" height="18" rx="2" />
|
||||
<path d="M6 8h12M6 12h12M6 16h8" />
|
||||
</svg>
|
||||
|
||||
{/* Badge with count */}
|
||||
<span className="jobs-count-badge">
|
||||
{totalCount > 99 ? "99+" : totalCount}
|
||||
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-bold text-white bg-current rounded-full">
|
||||
<span className="text-background">{totalCount > 99 ? "99+" : totalCount}</span>
|
||||
</span>
|
||||
|
||||
{/* Chevron */}
|
||||
<svg
|
||||
className={`jobs-chevron ${isOpen ? 'open' : ''}`}
|
||||
width="16"
|
||||
height="16"
|
||||
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -119,13 +124,13 @@ export function JobsIndicator() {
|
||||
|
||||
{/* Popin/Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="jobs-popin">
|
||||
<div className="jobs-popin-header">
|
||||
<div className="jobs-popin-title">
|
||||
<span className="jobs-icon-large">📊</span>
|
||||
<div className="absolute right-0 top-full mt-2 w-96 bg-card rounded-xl shadow-card border border-line overflow-hidden z-50">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-line bg-muted/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📊</span>
|
||||
<div>
|
||||
<h3>Active Jobs</h3>
|
||||
<p className="jobs-subtitle">
|
||||
<h3 className="font-semibold text-foreground">Active Jobs</h3>
|
||||
<p className="text-xs text-muted">
|
||||
{runningJobs.length > 0
|
||||
? `${runningJobs.length} running, ${pendingJobs.length} pending`
|
||||
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
|
||||
@@ -135,7 +140,7 @@ export function JobsIndicator() {
|
||||
</div>
|
||||
<Link
|
||||
href="/jobs"
|
||||
className="jobs-view-all"
|
||||
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
View All →
|
||||
@@ -144,72 +149,74 @@ export function JobsIndicator() {
|
||||
|
||||
{/* Overall progress bar if running */}
|
||||
{runningJobs.length > 0 && (
|
||||
<div className="jobs-overall-progress">
|
||||
<div className="progress-header">
|
||||
<span>Overall Progress</span>
|
||||
<span className="progress-percent">{Math.round(totalProgress)}%</span>
|
||||
<div className="px-4 py-3 border-b border-line">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-muted">Overall Progress</span>
|
||||
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div className="h-2 bg-line rounded-full overflow-hidden">
|
||||
<div
|
||||
className="progress-fill"
|
||||
className="h-full bg-success rounded-full transition-all duration-500"
|
||||
style={{ width: `${totalProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="jobs-list-container">
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{activeJobs.length === 0 ? (
|
||||
<div className="jobs-empty-state">
|
||||
<span className="empty-icon">✅</span>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted">
|
||||
<span className="text-4xl mb-2">✅</span>
|
||||
<p>No active jobs</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="jobs-detailed-list">
|
||||
<ul className="divide-y divide-line">
|
||||
{activeJobs.map(job => (
|
||||
<li key={job.id} className={`job-detailed-item job-status-${job.status}`}>
|
||||
<li key={job.id}>
|
||||
<Link
|
||||
href={`/jobs/${job.id}`}
|
||||
className="job-link"
|
||||
className="block px-4 py-3 hover:bg-muted/5 transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<div className="job-info-row">
|
||||
<div className="job-status-icon">
|
||||
{job.status === "running" && <span className="spinning">⏳</span>}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{job.status === "running" && <span className="animate-spin inline-block">⏳</span>}
|
||||
{job.status === "pending" && <span>⏸</span>}
|
||||
</div>
|
||||
|
||||
<div className="job-details">
|
||||
<div className="job-main-info">
|
||||
<code className="job-id-short">{job.id.slice(0, 8)}</code>
|
||||
<span className={`job-type-badge ${job.type}`}>{job.type}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<code className="text-xs px-1.5 py-0.5 bg-line/50 rounded font-mono">{job.id.slice(0, 8)}</code>
|
||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||||
job.type === 'rebuild' ? 'bg-primary-soft text-primary' : 'bg-muted/20 text-muted'
|
||||
}`}>
|
||||
{job.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{job.status === "running" && job.progress_percent !== null && (
|
||||
<div className="job-progress-row">
|
||||
<div className="job-mini-progress-bar">
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex-1 h-1.5 bg-line rounded-full overflow-hidden">
|
||||
<div
|
||||
className="job-mini-progress-fill"
|
||||
className="h-full bg-success rounded-full transition-all duration-300"
|
||||
style={{ width: `${job.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="job-progress-text">{job.progress_percent}%</span>
|
||||
<span className="text-xs font-medium text-muted">{job.progress_percent}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.current_file && (
|
||||
<p className="job-current-file" title={job.current_file}>
|
||||
📄 {job.current_file.length > 35
|
||||
? job.current_file.substring(0, 35) + "..."
|
||||
: job.current_file}
|
||||
<p className="text-xs text-muted mt-1.5 truncate" title={job.current_file}>
|
||||
📄 {job.current_file}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{job.stats_json && (
|
||||
<div className="job-mini-stats">
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted">
|
||||
<span>✓ {job.stats_json.indexed_files}</span>
|
||||
{job.stats_json.errors > 0 && (
|
||||
<span className="error-stat">⚠ {job.stats_json.errors}</span>
|
||||
<span className="text-error">⚠ {job.stats_json.errors}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -223,8 +230,8 @@ export function JobsIndicator() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="jobs-popin-footer">
|
||||
<p className="jobs-auto-refresh">Auto-refreshing every 2s</p>
|
||||
<div className="px-4 py-2 border-t border-line bg-muted/5">
|
||||
<p className="text-xs text-muted text-center">Auto-refreshing every 2s</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -64,28 +64,32 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
};
|
||||
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Library</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
||||
highlighted={job.id === highlightJobId}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-muted/5">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Library</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Created</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{jobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
||||
highlighted={job.id === highlightJobId}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
119
apps/backoffice/app/components/LibraryActions.tsx
Normal file
119
apps/backoffice/app/components/LibraryActions.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Badge } from "../components/ui";
|
||||
|
||||
interface LibraryActionsProps {
|
||||
libraryId: string;
|
||||
monitorEnabled: boolean;
|
||||
scanMode: string;
|
||||
watcherEnabled: boolean;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export function LibraryActions({
|
||||
libraryId,
|
||||
monitorEnabled,
|
||||
scanMode,
|
||||
watcherEnabled,
|
||||
onUpdate
|
||||
}: LibraryActionsProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
startTransition(async () => {
|
||||
const data = {
|
||||
monitor_enabled: formData.get("monitor_enabled") === "true",
|
||||
scan_mode: formData.get("scan_mode") as string,
|
||||
watcher_enabled: formData.get("watcher_enabled") === "true",
|
||||
};
|
||||
|
||||
await fetch(`/api/libraries/${libraryId}/monitoring`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
onUpdate?.();
|
||||
window.location.reload();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={isOpen ? "bg-muted/10" : ""}
|
||||
>
|
||||
⚙️
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-card border border-line p-4 z-50">
|
||||
<form action={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">🔄 Auto Scan</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="monitor_enabled"
|
||||
defaultChecked={monitorEnabled}
|
||||
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">⚡ File Watcher</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="watcher_enabled"
|
||||
defaultChecked={watcherEnabled}
|
||||
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">📅 Schedule</label>
|
||||
<select
|
||||
name="scan_mode"
|
||||
defaultValue={scanMode}
|
||||
className="text-sm border border-line rounded-lg px-2 py-1 bg-background"
|
||||
>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,28 +34,38 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="monitoring-form-compact">
|
||||
<form action={handleSubmit} className="flex items-center gap-2">
|
||||
<input type="hidden" name="id" value={libraryId} />
|
||||
|
||||
<div className="monitor-row">
|
||||
<label className={`monitor-checkbox ${isPending ? 'pending' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
|
||||
isPending
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:border-primary'
|
||||
} ${monitorEnabled ? 'bg-primary-soft border-primary text-primary' : 'bg-card border-line text-muted'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="monitor_enabled"
|
||||
value="true"
|
||||
defaultChecked={monitorEnabled}
|
||||
disabled={isPending}
|
||||
className="w-3.5 h-3.5 rounded border-line text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>Auto</span>
|
||||
</label>
|
||||
|
||||
<label className={`monitor-checkbox watcher ${isPending ? 'pending' : ''} ${watcherEnabled ? 'active' : ''}`}>
|
||||
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
|
||||
isPending
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:border-primary'
|
||||
} ${watcherEnabled ? 'bg-warning-soft border-warning text-warning' : 'bg-card border-line text-muted'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="watcher_enabled"
|
||||
value="true"
|
||||
defaultChecked={watcherEnabled}
|
||||
disabled={isPending}
|
||||
className="w-3.5 h-3.5 rounded border-line text-warning focus:ring-warning"
|
||||
/>
|
||||
<span title="Real-time file watcher">⚡</span>
|
||||
</label>
|
||||
@@ -64,7 +74,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
name="scan_mode"
|
||||
defaultValue={scanMode}
|
||||
disabled={isPending}
|
||||
className="scan-mode-select"
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-line bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
|
||||
>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="hourly">Hourly</option>
|
||||
@@ -72,7 +82,11 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
|
||||
<button type="submit" className="save-btn" disabled={isPending}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary text-white font-semibold text-sm transition-all hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isPending ? '...' : '✓'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
61
apps/backoffice/app/components/ui/Badge.tsx
Normal file
61
apps/backoffice/app/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
type BadgeVariant = "default" | "primary" | "success" | "warning" | "error" | "muted";
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
default: "bg-muted/20 text-muted",
|
||||
primary: "bg-primary-soft text-primary",
|
||||
success: "bg-success-soft text-success",
|
||||
warning: "bg-warning-soft text-warning",
|
||||
error: "bg-error-soft text-error",
|
||||
muted: "bg-muted/10 text-muted",
|
||||
};
|
||||
|
||||
export function Badge({ children, variant = "default", className = "" }: BadgeProps) {
|
||||
return (
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${variantStyles[variant]} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusVariant = "running" | "success" | "failed" | "cancelled" | "pending";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusVariants: Record<StatusVariant, BadgeVariant> = {
|
||||
running: "primary",
|
||||
success: "success",
|
||||
failed: "error",
|
||||
cancelled: "muted",
|
||||
pending: "warning",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
||||
const variant = statusVariants[status as StatusVariant] || "default";
|
||||
return <Badge variant={variant} className={className}>{status}</Badge>;
|
||||
}
|
||||
|
||||
type JobTypeVariant = "rebuild" | "full_rebuild";
|
||||
|
||||
interface JobTypeBadgeProps {
|
||||
type: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const jobTypeVariants: Record<JobTypeVariant, BadgeVariant> = {
|
||||
rebuild: "primary",
|
||||
full_rebuild: "warning",
|
||||
};
|
||||
|
||||
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
||||
const variant = jobTypeVariants[type as JobTypeVariant] || "default";
|
||||
return <Badge variant={variant} className={className}>{type}</Badge>;
|
||||
}
|
||||
48
apps/backoffice/app/components/ui/Button.tsx
Normal file
48
apps/backoffice/app/components/ui/Button.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "warning" | "ghost";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: "bg-primary text-white hover:bg-primary/90",
|
||||
secondary: "border border-line text-muted hover:bg-muted/5",
|
||||
danger: "bg-error text-white hover:bg-error/90",
|
||||
warning: "bg-warning text-white hover:bg-warning/90",
|
||||
ghost: "text-muted hover:text-foreground hover:bg-muted/5",
|
||||
};
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
inline-flex items-center justify-center font-medium rounded-lg transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
27
apps/backoffice/app/components/ui/Card.tsx
Normal file
27
apps/backoffice/app/components/ui/Card.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ children, className = "" }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-card rounded-xl shadow-soft border border-line p-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardHeader({ title, className = "" }: CardHeaderProps) {
|
||||
return (
|
||||
<h2 className={`text-lg font-semibold text-foreground mb-4 ${className}`}>
|
||||
{title}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
57
apps/backoffice/app/components/ui/Form.tsx
Normal file
57
apps/backoffice/app/components/ui/Form.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ReactNode, LabelHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes } from "react";
|
||||
|
||||
interface FormFieldProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormField({ children, className = "" }: FormFieldProps) {
|
||||
return <div className={`flex-1 min-w-48 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FormLabel({ children, className = "", ...props }: FormLabelProps) {
|
||||
return (
|
||||
<label className={`block text-sm font-medium text-foreground mb-1.5 ${className}`} {...props}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export function FormInput({ className = "", ...props }: FormInputProps) {
|
||||
return (
|
||||
<input
|
||||
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FormSelect({ children, className = "", ...props }: FormSelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormRowProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormRow({ children, className = "" }: FormRowProps) {
|
||||
return <div className={`flex items-end gap-3 flex-wrap ${className}`}>{children}</div>;
|
||||
}
|
||||
94
apps/backoffice/app/components/ui/Icon.tsx
Normal file
94
apps/backoffice/app/components/ui/Icon.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
type IconName = "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "series";
|
||||
|
||||
interface PageIconProps {
|
||||
name: IconName;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const icons: Record<IconName, React.ReactNode> = {
|
||||
dashboard: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
books: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
),
|
||||
libraries: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
jobs: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
tokens: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
),
|
||||
series: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const colors: Record<IconName, string> = {
|
||||
dashboard: "text-primary",
|
||||
books: "text-success",
|
||||
libraries: "text-primary",
|
||||
jobs: "text-warning",
|
||||
tokens: "text-error",
|
||||
series: "text-primary",
|
||||
};
|
||||
|
||||
export function PageIcon({ name, className = "" }: PageIconProps) {
|
||||
return (
|
||||
<span className={`${colors[name]} ${className}`}>
|
||||
{icons[name]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Nav icons (smaller)
|
||||
export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) {
|
||||
const navIcons: Record<IconName, React.ReactNode> = {
|
||||
dashboard: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
books: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
),
|
||||
libraries: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
jobs: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
tokens: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
),
|
||||
series: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return <span className={className}>{navIcons[name]}</span>;
|
||||
}
|
||||
30
apps/backoffice/app/components/ui/Input.tsx
Normal file
30
apps/backoffice/app/components/ui/Input.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, className = "", ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={`px-4 py-2.5 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Select({ label, children, className = "", ...props }: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={`px-4 py-2.5 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
56
apps/backoffice/app/components/ui/ProgressBar.tsx
Normal file
56
apps/backoffice/app/components/ui/ProgressBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
interface ProgressBarProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
showLabel?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: "h-1.5",
|
||||
md: "h-2",
|
||||
lg: "h-8",
|
||||
};
|
||||
|
||||
export function ProgressBar({
|
||||
value,
|
||||
max = 100,
|
||||
showLabel = false,
|
||||
size = "md",
|
||||
className = ""
|
||||
}: ProgressBarProps) {
|
||||
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
|
||||
return (
|
||||
<div className={`relative ${sizeStyles[size]} bg-line rounded-full overflow-hidden ${className}`}>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-success rounded-full transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
{showLabel && (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-sm font-semibold text-foreground">
|
||||
{Math.round(percent)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MiniProgressBarProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MiniProgressBar({ value, max = 100, className = "" }: MiniProgressBarProps) {
|
||||
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
|
||||
return (
|
||||
<div className={`flex-1 h-1.5 bg-line rounded-full overflow-hidden ${className}`}>
|
||||
<div
|
||||
className="h-full bg-success rounded-full transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/backoffice/app/components/ui/StatBox.tsx
Normal file
33
apps/backoffice/app/components/ui/StatBox.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface StatBoxProps {
|
||||
value: ReactNode;
|
||||
label: string;
|
||||
variant?: "default" | "primary" | "success" | "warning" | "error";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, string> = {
|
||||
default: "bg-muted/5",
|
||||
primary: "bg-primary-soft",
|
||||
success: "bg-success-soft",
|
||||
warning: "bg-warning-soft",
|
||||
error: "bg-error-soft",
|
||||
};
|
||||
|
||||
const valueVariantStyles: Record<string, string> = {
|
||||
default: "text-foreground",
|
||||
primary: "text-primary",
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
error: "text-error",
|
||||
};
|
||||
|
||||
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
|
||||
return (
|
||||
<div className={`text-center p-4 rounded-lg ${variantStyles[variant]} ${className}`}>
|
||||
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
|
||||
<span className={`text-xs ${valueVariantStyles[variant]}/80`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
apps/backoffice/app/components/ui/index.ts
Normal file
8
apps/backoffice/app/components/ui/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { Card, CardHeader } from "./Card";
|
||||
export { Badge, StatusBadge, JobTypeBadge } from "./Badge";
|
||||
export { StatBox } from "./StatBox";
|
||||
export { ProgressBar, MiniProgressBar } from "./ProgressBar";
|
||||
export { Button } from "./Button";
|
||||
export { Input, Select } from "./Input";
|
||||
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
|
||||
export { PageIcon, NavIcon } from "./Icon";
|
||||
@@ -17,6 +17,9 @@
|
||||
--color-error-soft: hsl(2 72% 90%);
|
||||
|
||||
--font-sans: "Avenir Next", "Segoe UI", "Noto Sans", system-ui, sans-serif;
|
||||
|
||||
--shadow-soft: 0 1px 2px 0 rgb(23 32 46 / 0.06);
|
||||
--shadow-card: 0 12px 30px -12px rgb(23 32 46 / 0.22);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -88,3 +91,18 @@ html {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom utilities - use directly in components */
|
||||
.shadow-soft {
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.shadow-card {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
|
||||
interface JobDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -83,170 +84,143 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<Link href="/jobs" className="back-link">← Back to jobs</Link>
|
||||
<h1>Job Details</h1>
|
||||
<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>
|
||||
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
|
||||
</div>
|
||||
|
||||
<div className="job-detail-grid">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Overview Card */}
|
||||
<div className="card job-overview">
|
||||
<h2>Overview</h2>
|
||||
<div className="job-meta">
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">ID</span>
|
||||
<code className="meta-value">{job.id}</code>
|
||||
<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>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Type</span>
|
||||
<span className={`meta-value job-type ${job.type}`}>{job.type}</span>
|
||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
||||
<span className="text-sm text-muted">Type</span>
|
||||
<JobTypeBadge type={job.type} />
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Status</span>
|
||||
<span className={`meta-value status-badge status-${job.status}`}>{job.status}</span>
|
||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
||||
<span className="text-sm text-muted">Status</span>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Library</span>
|
||||
<span className="meta-value">{job.library_id || "All libraries"}</span>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-muted">Library</span>
|
||||
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Timeline Card */}
|
||||
<div className="card job-timeline">
|
||||
<h2>Timeline</h2>
|
||||
<div className="timeline">
|
||||
<div className={`timeline-item ${job.created_at ? 'completed' : ''}`}>
|
||||
<div className="timeline-dot" />
|
||||
<div className="timeline-content">
|
||||
<span className="timeline-label">Created</span>
|
||||
<span className="timeline-time">{new Date(job.created_at).toLocaleString()}</span>
|
||||
<Card>
|
||||
<CardHeader title="Timeline" />
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`timeline-item ${job.started_at ? 'completed' : ''} ${!job.started_at ? 'pending' : ''}`}>
|
||||
<div className="timeline-dot" />
|
||||
<div className="timeline-content">
|
||||
<span className="timeline-label">Started</span>
|
||||
<span className="timeline-time">
|
||||
<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">
|
||||
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`timeline-item ${job.finished_at ? 'completed' : ''} ${job.started_at && !job.finished_at ? 'active' : ''} ${!job.started_at ? 'pending' : ''}`}>
|
||||
<div className="timeline-dot" />
|
||||
<div className="timeline-content">
|
||||
<span className="timeline-label">Finished</span>
|
||||
<span className="timeline-time">
|
||||
<div className="flex items-start gap-4">
|
||||
<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">
|
||||
{job.finished_at
|
||||
? new Date(job.finished_at).toLocaleString()
|
||||
: job.started_at
|
||||
? "Running..."
|
||||
: "Waiting..."
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{job.started_at && (
|
||||
<div className="duration-badge">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Progress Card */}
|
||||
{(job.status === "running" || job.status === "success" || job.status === "failed") && (
|
||||
<div className="card job-progress-detail">
|
||||
<h2>Progress</h2>
|
||||
<Card>
|
||||
<CardHeader title="Progress" />
|
||||
{job.total_files && job.total_files > 0 && (
|
||||
<>
|
||||
<div className="progress-bar-large">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${job.progress_percent || 0}%` }}
|
||||
/>
|
||||
<span className="progress-text">{job.progress_percent || 0}%</span>
|
||||
</div>
|
||||
<div className="progress-stats-grid">
|
||||
<div className="stat-box">
|
||||
<span className="stat-value">{job.processed_files || 0}</span>
|
||||
<span className="stat-label">Processed</span>
|
||||
</div>
|
||||
<div className="stat-box">
|
||||
<span className="stat-value">{job.total_files}</span>
|
||||
<span className="stat-label">Total</span>
|
||||
</div>
|
||||
<div className="stat-box">
|
||||
<span className="stat-value">{job.total_files - (job.processed_files || 0)}</span>
|
||||
<span className="stat-label">Remaining</span>
|
||||
</div>
|
||||
<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="current-file-box">
|
||||
<span className="label">Current file:</span>
|
||||
<code className="file-path">{job.current_file}</code>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Statistics Card */}
|
||||
{job.stats_json && (
|
||||
<div className="card job-statistics">
|
||||
<h2>Statistics</h2>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-item">
|
||||
<span className="stat-number success">{job.stats_json.scanned_files}</span>
|
||||
<span className="stat-label">Scanned</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-number primary">{job.stats_json.indexed_files}</span>
|
||||
<span className="stat-label">Indexed</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-number warning">{job.stats_json.removed_files}</span>
|
||||
<span className="stat-label">Removed</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className={`stat-number ${job.stats_json.errors > 0 ? 'error' : ''}`}>
|
||||
{job.stats_json.errors}
|
||||
</span>
|
||||
<span className="stat-label">Errors</span>
|
||||
</div>
|
||||
<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="speed-stat">
|
||||
<span className="speed-label">Speed:</span>
|
||||
<span className="speed-value">{formatSpeed(job.stats_json, duration)}</span>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Errors Card */}
|
||||
{errors.length > 0 && (
|
||||
<div className="card job-errors">
|
||||
<h2>Errors ({errors.length})</h2>
|
||||
<div className="errors-list">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader title={`Errors (${errors.length})`} />
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{errors.map((error) => (
|
||||
<div key={error.id} className="error-item">
|
||||
<code className="error-file">{error.file_path}</code>
|
||||
<span className="error-message">{error.error_message}</span>
|
||||
<span className="error-time">{new Date(error.created_at).toLocaleString()}</span>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{job.error_opt && (
|
||||
<div className="card job-error-message">
|
||||
<h2>Error</h2>
|
||||
<pre className="error-details">{job.error_opt}</pre>
|
||||
</div>
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -2,6 +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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -30,36 +31,47 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
}
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||
const apiToken = process.env.API_BOOTSTRAP_TOKEN || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Index Jobs</h1>
|
||||
<div className="card">
|
||||
<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>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form action={triggerRebuild}>
|
||||
<select name="library_id" defaultValue="">
|
||||
<option value="">All libraries</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">Queue Rebuild</button>
|
||||
<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>
|
||||
<form action={triggerFullRebuild} style={{ marginTop: '12px' }}>
|
||||
<select name="library_id" defaultValue="">
|
||||
<option value="">All libraries</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="full-rebuild-btn">Full Rebuild (Reindex All)</button>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<JobsList
|
||||
initialJobs={jobs}
|
||||
libraries={libraryMap}
|
||||
|
||||
@@ -6,6 +6,7 @@ import "./globals.css";
|
||||
import { ThemeProvider } from "./theme-provider";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { JobsIndicator } from "./components/JobsIndicator";
|
||||
import { NavIcon } from "./components/ui";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Stripstream Backoffice",
|
||||
@@ -38,11 +39,21 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
{/* Navigation Links */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
<NavLink href="/">Dashboard</NavLink>
|
||||
<NavLink href="/books">Books</NavLink>
|
||||
<NavLink href="/libraries">Libraries</NavLink>
|
||||
<NavLink href="/jobs">Jobs</NavLink>
|
||||
<NavLink href="/tokens">Tokens</NavLink>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pl-6 border-l border-line">
|
||||
@@ -68,7 +79,7 @@ function NavLink({ href, children }: { href: "/" | "/books" | "/libraries" | "/j
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="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 gap-2 px-3 py-2 rounded-md text-sm font-medium text-foreground/80 hover:text-foreground hover:bg-primary-soft transition-colors"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
@@ -1,11 +1,79 @@
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<h1>Stripstream Backoffice</h1>
|
||||
<p>Manage libraries, indexing jobs, and API tokens from a Next.js admin interface.</p>
|
||||
<div className="card">
|
||||
<p>Use the navigation links above to access each admin section.</p>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-4">
|
||||
Stripstream Backoffice
|
||||
</h1>
|
||||
<p className="text-lg text-muted 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">
|
||||
{/* 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"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<div className="w-12 h-12 bg-success-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-success transition-colors">
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<div className="w-12 h-12 bg-warning-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-warning transition-colors">
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 p-6 bg-primary-soft rounded-xl border border-primary/20">
|
||||
<h2 className="text-lg font-semibold mb-2 text-primary">Getting Started</h2>
|
||||
<p className="text-muted">
|
||||
Start by creating a library from your comic folders, then trigger an index job to scan your collection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -32,57 +33,78 @@ export default async function TokensPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>API Tokens</h1>
|
||||
<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>
|
||||
|
||||
{params.created ? (
|
||||
<div className="card">
|
||||
<strong>Token created (copy it now, it won't be shown again):</strong>
|
||||
<pre>{params.created}</pre>
|
||||
</div>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
<div className="card">
|
||||
<Card className="mb-6">
|
||||
<form action={createTokenAction}>
|
||||
<input name="name" placeholder="token name" required />
|
||||
<select name="scope" defaultValue="read">
|
||||
<option value="read">read</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button type="submit">Create Token</button>
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Scope</th>
|
||||
<th>Prefix</th>
|
||||
<th>Revoked</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => (
|
||||
<tr key={token.id}>
|
||||
<td>{token.name}</td>
|
||||
<td>{token.scope}</td>
|
||||
<td>
|
||||
<code>{token.prefix}</code>
|
||||
</td>
|
||||
<td>{token.revoked_at ? "yes" : "no"}</td>
|
||||
<td>
|
||||
{!token.revoked_at && (
|
||||
<form action={revokeTokenAction}>
|
||||
<input type="hidden" name="id" value={token.id} />
|
||||
<button type="submit">Revoke</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{tokens.map((token) => (
|
||||
<tr key={token.id} className="hover:bg-muted/5">
|
||||
<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>
|
||||
</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>
|
||||
) : (
|
||||
<span className="inline-flex px-2 py-1 rounded-full text-xs font-semibold bg-success-soft text-success">no</span>
|
||||
)}
|
||||
</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>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user