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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="breadcrumb">
|
<div className="mb-6">
|
||||||
<Link href="/books">← Back to books</Link>
|
<Link href="/books" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
|
||||||
|
← Back to books
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="book-detail">
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
<div className="book-detail-cover">
|
<div className="flex-shrink-0">
|
||||||
<Image
|
<div className="bg-card rounded-xl shadow-card border border-line p-4 inline-block">
|
||||||
src={getBookCoverUrl(book.id)}
|
<Image
|
||||||
alt={`Cover of ${book.title}`}
|
src={getBookCoverUrl(book.id)}
|
||||||
width={300}
|
alt={`Cover of ${book.title}`}
|
||||||
height={440}
|
width={300}
|
||||||
className="detail-cover-image"
|
height={440}
|
||||||
unoptimized
|
className="w-auto h-auto max-w-[300px] rounded-lg"
|
||||||
loading="lazy"
|
unoptimized
|
||||||
/>
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="book-detail-info">
|
<div className="flex-1">
|
||||||
<h1>{book.title}</h1>
|
<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.author && (
|
{book.author && (
|
||||||
<p className="detail-author">by {book.author}</p>
|
<p className="text-lg text-muted mb-4">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>
|
|
||||||
|
|
||||||
{book.volume && (
|
|
||||||
<div className="meta-row">
|
|
||||||
<span className="meta-label">Volume:</span>
|
|
||||||
<span>{book.volume}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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 && (
|
{book.series && (
|
||||||
<div className="meta-row">
|
<p className="text-sm text-muted mb-6">
|
||||||
<span className="meta-label">Series:</span>
|
{book.series}
|
||||||
<span>{book.series}</span>
|
{book.volume && <span className="ml-2 px-2 py-1 bg-primary-soft text-primary rounded text-xs">Volume {book.volume}</span>}
|
||||||
</div>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.file_format && (
|
<div className="space-y-3">
|
||||||
<div className="meta-row">
|
<div className="flex items-center justify-between py-2 border-b border-line">
|
||||||
<span className="meta-label">File Format:</span>
|
<span className="text-sm text-muted">Format:</span>
|
||||||
<span>{book.file_format.toUpperCase()}</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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{book.file_parse_status && (
|
{book.volume && (
|
||||||
<div className="meta-row">
|
<div className="flex items-center justify-between py-2 border-b border-line">
|
||||||
<span className="meta-label">Parse Status:</span>
|
<span className="text-sm text-muted">Volume:</span>
|
||||||
<span className={`status-${book.file_parse_status}`}>{book.file_parse_status}</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{book.file_path && (
|
{book.series && (
|
||||||
<div className="meta-row">
|
<div className="flex items-center justify-between py-2 border-b border-line">
|
||||||
<span className="meta-label">File Path:</span>
|
<span className="text-sm text-muted">Series:</span>
|
||||||
<code className="file-path">{book.file_path}</code>
|
<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>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="meta-row">
|
<div className="flex flex-col py-2 border-b border-line">
|
||||||
<span className="meta-label">Book ID:</span>
|
<span className="text-sm text-muted mb-1">Library ID:</span>
|
||||||
<code className="book-id">{book.id}</code>
|
<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>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
|
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
|
||||||
import { BooksGrid, EmptyState } from "../components/BookCard";
|
import { BooksGrid, EmptyState } from "../components/BookCard";
|
||||||
|
import { Card, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -50,39 +51,55 @@ export default async function BooksPage({
|
|||||||
nextCursor = booksPage.next_cursor;
|
nextCursor = booksPage.next_cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayBooks = searchResults || books;
|
const displayBooks = (searchResults || books).map(book => ({
|
||||||
|
...book,
|
||||||
|
coverUrl: getBookCoverUrl(book.id)
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Filtres et recherche */}
|
||||||
<div className="card">
|
<Card className="mb-6">
|
||||||
<form className="search-form">
|
<form>
|
||||||
<input
|
<FormRow>
|
||||||
name="q"
|
<FormField>
|
||||||
placeholder="Search books..."
|
<FormInput
|
||||||
defaultValue={searchQuery}
|
name="q"
|
||||||
className="search-input"
|
placeholder="Search books..."
|
||||||
/>
|
defaultValue={searchQuery}
|
||||||
<select name="library" defaultValue={libraryId || ""}>
|
/>
|
||||||
<option value="">All libraries</option>
|
</FormField>
|
||||||
{libraries.map((lib) => (
|
<FormField>
|
||||||
<option key={lib.id} value={lib.id}>
|
<FormSelect name="library" defaultValue={libraryId || ""}>
|
||||||
{lib.name}
|
<option value="">All libraries</option>
|
||||||
</option>
|
{libraries.map((lib) => (
|
||||||
))}
|
<option key={lib.id} value={lib.id}>
|
||||||
</select>
|
{lib.name}
|
||||||
<button type="submit">Search</button>
|
</option>
|
||||||
{searchQuery && (
|
))}
|
||||||
<Link href="/books" className="button secondary">Clear</Link>
|
</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>
|
</form>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Résultats de recherche */}
|
{/* Résultats de recherche */}
|
||||||
{searchQuery && totalHits !== null && (
|
{searchQuery && totalHits !== null && (
|
||||||
<p className="results-info">
|
<p className="text-sm text-muted mb-4">
|
||||||
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -90,15 +107,20 @@ export default async function BooksPage({
|
|||||||
{/* Grille de livres */}
|
{/* Grille de livres */}
|
||||||
{displayBooks.length > 0 ? (
|
{displayBooks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<BooksGrid books={displayBooks} getBookCoverUrl={getBookCoverUrl} />
|
<BooksGrid books={displayBooks} />
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{!searchQuery && nextCursor && (
|
{!searchQuery && nextCursor && (
|
||||||
<div className="pagination">
|
<div className="flex justify-center mt-8">
|
||||||
<form>
|
<form>
|
||||||
<input type="hidden" name="library" value={libraryId || ""} />
|
<input type="hidden" name="library" value={libraryId || ""} />
|
||||||
<input type="hidden" name="cursor" value={nextCursor} />
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,42 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookDto } from "../../lib/api";
|
import { BookDto } from "../../lib/api";
|
||||||
|
|
||||||
interface BookCardProps {
|
interface BookCardProps {
|
||||||
book: BookDto;
|
book: BookDto & { coverUrl?: string };
|
||||||
getBookCoverUrl: (bookId: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BookCard({ book, getBookCoverUrl }: BookCardProps) {
|
function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/books/${book.id}`} className="book-card">
|
<div className="relative aspect-[2/3] overflow-hidden bg-gradient-to-br from-line/50 to-line">
|
||||||
<div className="book-cover">
|
{/* Skeleton */}
|
||||||
<Image
|
<div
|
||||||
src={getBookCoverUrl(book.id)}
|
className={`absolute inset-0 bg-muted/10 animate-pulse transition-opacity duration-300 ${
|
||||||
alt={`Cover of ${book.title}`}
|
isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||||
width={150}
|
}`}
|
||||||
height={220}
|
>
|
||||||
className="cover-image"
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/20 to-transparent shimmer" />
|
||||||
unoptimized
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
</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}
|
{book.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{book.author && (
|
{book.author && (
|
||||||
<p className="book-author">{book.author}</p>
|
<p className="text-sm text-muted mb-1 truncate">{book.author}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.series && (
|
{book.series && (
|
||||||
<p className="book-series">
|
<p className="text-xs text-muted/80 truncate mb-2">
|
||||||
{book.series}
|
{book.series}
|
||||||
{book.volume && ` #${book.volume}`}
|
{book.volume && <span className="text-primary font-medium"> #{book.volume}</span>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="book-meta">
|
|
||||||
<span className={`book-kind ${book.kind}`}>{book.kind.toUpperCase()}</span>
|
{/* Meta Tags */}
|
||||||
{book.language && <span className="book-lang">{book.language.toUpperCase()}</span>}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -44,15 +94,14 @@ export function BookCard({ book, getBookCoverUrl }: BookCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BooksGridProps {
|
interface BooksGridProps {
|
||||||
books: BookDto[];
|
books: (BookDto & { coverUrl?: string })[];
|
||||||
getBookCoverUrl: (bookId: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BooksGrid({ books, getBookCoverUrl }: BooksGridProps) {
|
export function BooksGrid({ books }: BooksGridProps) {
|
||||||
return (
|
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) => (
|
{books.map((book) => (
|
||||||
<BookCard key={book.id} book={book} getBookCoverUrl={getBookCoverUrl} />
|
<BookCard key={book.id} book={book} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -64,8 +113,13 @@ interface EmptyStateProps {
|
|||||||
|
|
||||||
export function EmptyState({ message }: EmptyStateProps) {
|
export function EmptyState({ message }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="empty-state">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<p>{message}</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { StatusBadge, Badge, ProgressBar } from "./ui";
|
||||||
|
|
||||||
interface ProgressEvent {
|
interface ProgressEvent {
|
||||||
job_id: string;
|
job_id: string;
|
||||||
@@ -28,7 +29,6 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use SSE via local proxy
|
|
||||||
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
|
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
@@ -69,11 +69,19 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
}, [jobId, onComplete]);
|
}, [jobId, onComplete]);
|
||||||
|
|
||||||
if (error) {
|
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) {
|
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;
|
const percent = progress.progress_percent ?? 0;
|
||||||
@@ -81,26 +89,20 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
const total = progress.total_files ?? 0;
|
const total = progress.total_files ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="job-progress">
|
<div className="p-4 bg-card rounded-lg border border-line">
|
||||||
<div className="progress-header">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className={`status-badge status-${progress.status}`}>
|
<StatusBadge status={progress.status} />
|
||||||
{progress.status}
|
{isComplete && (
|
||||||
</span>
|
<Badge variant="success">Complete</Badge>
|
||||||
{isComplete && <span className="complete-badge">Complete</span>}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="progress-bar-container">
|
<ProgressBar value={percent} showLabel size="md" className="mb-3" />
|
||||||
<div
|
|
||||||
className="progress-bar-fill"
|
|
||||||
style={{ width: `${percent}%` }}
|
|
||||||
/>
|
|
||||||
<span className="progress-percent">{percent}%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
<span>{processed} / {total} files</span>
|
||||||
{progress.current_file && (
|
{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
|
Current: {progress.current_file.length > 40
|
||||||
? progress.current_file.substring(0, 40) + "..."
|
? progress.current_file.substring(0, 40) + "..."
|
||||||
: progress.current_file}
|
: progress.current_file}
|
||||||
@@ -109,12 +111,12 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{progress.stats_json && (
|
{progress.stats_json && (
|
||||||
<div className="progress-detailed-stats">
|
<div className="flex flex-wrap gap-3 text-xs">
|
||||||
<span>Scanned: {progress.stats_json.scanned_files}</span>
|
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
|
||||||
<span>Indexed: {progress.stats_json.indexed_files}</span>
|
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
|
||||||
<span>Removed: {progress.stats_json.removed_files}</span>
|
<Badge variant="warning">Removed: {progress.stats_json.removed_files}</Badge>
|
||||||
{progress.stats_json.errors > 0 && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { JobProgress } from "./JobProgress";
|
import { JobProgress } from "./JobProgress";
|
||||||
|
import { StatusBadge, Button } from "./ui";
|
||||||
|
|
||||||
interface JobRowProps {
|
interface JobRowProps {
|
||||||
job: {
|
job: {
|
||||||
@@ -25,52 +26,71 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
|||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
setShowProgress(false);
|
setShowProgress(false);
|
||||||
// Trigger a page refresh to update the job status
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr className={highlighted ? "job-highlighted" : undefined}>
|
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
|
||||||
<td>
|
<td className="px-4 py-3">
|
||||||
<Link href={`/jobs/${job.id}`} className="job-id-link">
|
<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>
|
<code>{job.id.slice(0, 8)}</code>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}</td>
|
<td className="px-4 py-3 text-sm text-foreground">
|
||||||
<td>{job.type}</td>
|
{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}
|
||||||
<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>
|
</td>
|
||||||
<td>{new Date(job.created_at).toLocaleString()}</td>
|
<td className="px-4 py-3 text-sm text-foreground">{job.type}</td>
|
||||||
<td>
|
<td className="px-4 py-3">
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Link href={`/jobs/${job.id}`} className="view-btn">
|
<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
|
View
|
||||||
</Link>
|
</Link>
|
||||||
{(job.status === "pending" || job.status === "running") && (
|
{(job.status === "pending" || job.status === "running") && (
|
||||||
<button
|
<Button
|
||||||
className="cancel-btn"
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
onClick={() => onCancel(job.id)}
|
onClick={() => onCancel(job.id)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{showProgress && (job.status === "running" || job.status === "pending") && (
|
{showProgress && (job.status === "running" || job.status === "pending") && (
|
||||||
<tr className="progress-row">
|
<tr>
|
||||||
<td colSpan={6}>
|
<td colSpan={6} className="px-4 py-3 bg-muted/5">
|
||||||
<JobProgress
|
<JobProgress
|
||||||
jobId={job.id}
|
jobId={job.id}
|
||||||
onComplete={handleComplete}
|
onComplete={handleComplete}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface Job {
|
|||||||
export function JobsIndicator() {
|
export function JobsIndicator() {
|
||||||
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,7 +65,11 @@ export function JobsIndicator() {
|
|||||||
|
|
||||||
if (totalCount === 0) {
|
if (totalCount === 0) {
|
||||||
return (
|
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">
|
<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" />
|
<rect x="2" y="3" width="20" height="18" rx="2" />
|
||||||
<path d="M6 8h12M6 12h12M6 16h8" />
|
<path d="M6 8h12M6 12h12M6 16h8" />
|
||||||
@@ -76,15 +79,19 @@ export function JobsIndicator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="jobs-indicator-wrapper" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<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)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
|
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
|
||||||
>
|
>
|
||||||
{/* Animated spinner for running jobs */}
|
{/* Animated spinner for running jobs */}
|
||||||
{runningJobs.length > 0 && (
|
{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">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
||||||
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
||||||
@@ -93,21 +100,19 @@ export function JobsIndicator() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Icon */}
|
{/* 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" />
|
<rect x="2" y="3" width="20" height="18" rx="2" />
|
||||||
<path d="M6 8h12M6 12h12M6 16h8" />
|
<path d="M6 8h12M6 12h12M6 16h8" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Badge with count */}
|
{/* Badge with count */}
|
||||||
<span className="jobs-count-badge">
|
<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">
|
||||||
{totalCount > 99 ? "99+" : totalCount}
|
<span className="text-background">{totalCount > 99 ? "99+" : totalCount}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Chevron */}
|
{/* Chevron */}
|
||||||
<svg
|
<svg
|
||||||
className={`jobs-chevron ${isOpen ? 'open' : ''}`}
|
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -119,13 +124,13 @@ export function JobsIndicator() {
|
|||||||
|
|
||||||
{/* Popin/Dropdown */}
|
{/* Popin/Dropdown */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="jobs-popin">
|
<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="jobs-popin-header">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-line bg-muted/5">
|
||||||
<div className="jobs-popin-title">
|
<div className="flex items-center gap-3">
|
||||||
<span className="jobs-icon-large">📊</span>
|
<span className="text-2xl">📊</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>Active Jobs</h3>
|
<h3 className="font-semibold text-foreground">Active Jobs</h3>
|
||||||
<p className="jobs-subtitle">
|
<p className="text-xs text-muted">
|
||||||
{runningJobs.length > 0
|
{runningJobs.length > 0
|
||||||
? `${runningJobs.length} running, ${pendingJobs.length} pending`
|
? `${runningJobs.length} running, ${pendingJobs.length} pending`
|
||||||
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
|
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
|
||||||
@@ -135,7 +140,7 @@ export function JobsIndicator() {
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/jobs"
|
href="/jobs"
|
||||||
className="jobs-view-all"
|
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
View All →
|
View All →
|
||||||
@@ -144,72 +149,74 @@ export function JobsIndicator() {
|
|||||||
|
|
||||||
{/* Overall progress bar if running */}
|
{/* Overall progress bar if running */}
|
||||||
{runningJobs.length > 0 && (
|
{runningJobs.length > 0 && (
|
||||||
<div className="jobs-overall-progress">
|
<div className="px-4 py-3 border-b border-line">
|
||||||
<div className="progress-header">
|
<div className="flex items-center justify-between text-sm mb-2">
|
||||||
<span>Overall Progress</span>
|
<span className="text-muted">Overall Progress</span>
|
||||||
<span className="progress-percent">{Math.round(totalProgress)}%</span>
|
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="progress-bar">
|
<div className="h-2 bg-line rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="progress-fill"
|
className="h-full bg-success rounded-full transition-all duration-500"
|
||||||
style={{ width: `${totalProgress}%` }}
|
style={{ width: `${totalProgress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="jobs-list-container">
|
<div className="max-h-80 overflow-y-auto">
|
||||||
{activeJobs.length === 0 ? (
|
{activeJobs.length === 0 ? (
|
||||||
<div className="jobs-empty-state">
|
<div className="flex flex-col items-center justify-center py-8 text-muted">
|
||||||
<span className="empty-icon">✅</span>
|
<span className="text-4xl mb-2">✅</span>
|
||||||
<p>No active jobs</p>
|
<p>No active jobs</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="jobs-detailed-list">
|
<ul className="divide-y divide-line">
|
||||||
{activeJobs.map(job => (
|
{activeJobs.map(job => (
|
||||||
<li key={job.id} className={`job-detailed-item job-status-${job.status}`}>
|
<li key={job.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/jobs/${job.id}`}
|
href={`/jobs/${job.id}`}
|
||||||
className="job-link"
|
className="block px-4 py-3 hover:bg-muted/5 transition-colors"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<div className="job-info-row">
|
<div className="flex items-start gap-3">
|
||||||
<div className="job-status-icon">
|
<div className="mt-0.5">
|
||||||
{job.status === "running" && <span className="spinning">⏳</span>}
|
{job.status === "running" && <span className="animate-spin inline-block">⏳</span>}
|
||||||
{job.status === "pending" && <span>⏸</span>}
|
{job.status === "pending" && <span>⏸</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="job-details">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="job-main-info">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<code className="job-id-short">{job.id.slice(0, 8)}</code>
|
<code className="text-xs px-1.5 py-0.5 bg-line/50 rounded font-mono">{job.id.slice(0, 8)}</code>
|
||||||
<span className={`job-type-badge ${job.type}`}>{job.type}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{job.status === "running" && job.progress_percent !== null && (
|
{job.status === "running" && job.progress_percent !== null && (
|
||||||
<div className="job-progress-row">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<div className="job-mini-progress-bar">
|
<div className="flex-1 h-1.5 bg-line rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="job-mini-progress-fill"
|
className="h-full bg-success rounded-full transition-all duration-300"
|
||||||
style={{ width: `${job.progress_percent}%` }}
|
style={{ width: `${job.progress_percent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="job-progress-text">{job.progress_percent}%</span>
|
<span className="text-xs font-medium text-muted">{job.progress_percent}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job.current_file && (
|
{job.current_file && (
|
||||||
<p className="job-current-file" title={job.current_file}>
|
<p className="text-xs text-muted mt-1.5 truncate" title={job.current_file}>
|
||||||
📄 {job.current_file.length > 35
|
📄 {job.current_file}
|
||||||
? job.current_file.substring(0, 35) + "..."
|
|
||||||
: job.current_file}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job.stats_json && (
|
{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>
|
<span>✓ {job.stats_json.indexed_files}</span>
|
||||||
{job.stats_json.errors > 0 && (
|
{job.stats_json.errors > 0 && (
|
||||||
<span className="error-stat">⚠ {job.stats_json.errors}</span>
|
<span className="text-error">⚠ {job.stats_json.errors}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -223,8 +230,8 @@ export function JobsIndicator() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="jobs-popin-footer">
|
<div className="px-4 py-2 border-t border-line bg-muted/5">
|
||||||
<p className="jobs-auto-refresh">Auto-refreshing every 2s</p>
|
<p className="text-xs text-muted text-center">Auto-refreshing every 2s</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -64,28 +64,32 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table>
|
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden">
|
||||||
<thead>
|
<div className="overflow-x-auto">
|
||||||
<tr>
|
<table className="w-full">
|
||||||
<th>ID</th>
|
<thead>
|
||||||
<th>Library</th>
|
<tr className="border-b border-line bg-muted/5">
|
||||||
<th>Type</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">ID</th>
|
||||||
<th>Status</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Library</th>
|
||||||
<th>Created</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Type</th>
|
||||||
<th>Actions</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Status</th>
|
||||||
</tr>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Created</th>
|
||||||
</thead>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
|
||||||
<tbody>
|
</tr>
|
||||||
{jobs.map((job) => (
|
</thead>
|
||||||
<JobRow
|
<tbody className="divide-y divide-line">
|
||||||
key={job.id}
|
{jobs.map((job) => (
|
||||||
job={job}
|
<JobRow
|
||||||
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
key={job.id}
|
||||||
highlighted={job.id === highlightJobId}
|
job={job}
|
||||||
onCancel={handleCancel}
|
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
||||||
/>
|
highlighted={job.id === highlightJobId}
|
||||||
))}
|
onCancel={handleCancel}
|
||||||
</tbody>
|
/>
|
||||||
</table>
|
))}
|
||||||
|
</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 (
|
return (
|
||||||
<form action={handleSubmit} className="monitoring-form-compact">
|
<form action={handleSubmit} className="flex items-center gap-2">
|
||||||
<input type="hidden" name="id" value={libraryId} />
|
<input type="hidden" name="id" value={libraryId} />
|
||||||
|
|
||||||
<div className="monitor-row">
|
<div className="flex items-center gap-2">
|
||||||
<label className={`monitor-checkbox ${isPending ? 'pending' : ''}`}>
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="monitor_enabled"
|
name="monitor_enabled"
|
||||||
value="true"
|
value="true"
|
||||||
defaultChecked={monitorEnabled}
|
defaultChecked={monitorEnabled}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
|
className="w-3.5 h-3.5 rounded border-line text-primary focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<span>Auto</span>
|
<span>Auto</span>
|
||||||
</label>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="watcher_enabled"
|
name="watcher_enabled"
|
||||||
value="true"
|
value="true"
|
||||||
defaultChecked={watcherEnabled}
|
defaultChecked={watcherEnabled}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
|
className="w-3.5 h-3.5 rounded border-line text-warning focus:ring-warning"
|
||||||
/>
|
/>
|
||||||
<span title="Real-time file watcher">⚡</span>
|
<span title="Real-time file watcher">⚡</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -64,7 +74,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
name="scan_mode"
|
name="scan_mode"
|
||||||
defaultValue={scanMode}
|
defaultValue={scanMode}
|
||||||
disabled={isPending}
|
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="manual">Manual</option>
|
||||||
<option value="hourly">Hourly</option>
|
<option value="hourly">Hourly</option>
|
||||||
@@ -72,7 +82,11 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
<option value="weekly">Weekly</option>
|
<option value="weekly">Weekly</option>
|
||||||
</select>
|
</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 ? '...' : '✓'}
|
{isPending ? '...' : '✓'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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%);
|
--color-error-soft: hsl(2 72% 90%);
|
||||||
|
|
||||||
--font-sans: "Avenir Next", "Segoe UI", "Noto Sans", system-ui, sans-serif;
|
--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 {
|
.dark {
|
||||||
@@ -88,3 +91,18 @@ html {
|
|||||||
scroll-behavior: auto !important;
|
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 { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { apiFetch } from "../../../lib/api";
|
import { apiFetch } from "../../../lib/api";
|
||||||
|
import { Card, CardHeader, StatusBadge, JobTypeBadge, StatBox, ProgressBar } from "../../components/ui";
|
||||||
|
|
||||||
interface JobDetailPageProps {
|
interface JobDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -83,170 +84,143 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-header">
|
<div className="mb-6">
|
||||||
<Link href="/jobs" className="back-link">← Back to jobs</Link>
|
<Link href="/jobs" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
|
||||||
<h1>Job Details</h1>
|
← Back to jobs
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="job-detail-grid">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Overview Card */}
|
{/* Overview Card */}
|
||||||
<div className="card job-overview">
|
<Card>
|
||||||
<h2>Overview</h2>
|
<CardHeader title="Overview" />
|
||||||
<div className="job-meta">
|
<div className="space-y-3">
|
||||||
<div className="meta-item">
|
<div className="flex items-center justify-between py-2 border-b border-line">
|
||||||
<span className="meta-label">ID</span>
|
<span className="text-sm text-muted">ID</span>
|
||||||
<code className="meta-value">{job.id}</code>
|
<code className="px-2 py-1 bg-muted/10 rounded font-mono text-sm text-foreground">{job.id}</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="meta-item">
|
<div className="flex items-center justify-between py-2 border-b border-line">
|
||||||
<span className="meta-label">Type</span>
|
<span className="text-sm text-muted">Type</span>
|
||||||
<span className={`meta-value job-type ${job.type}`}>{job.type}</span>
|
<JobTypeBadge type={job.type} />
|
||||||
</div>
|
</div>
|
||||||
<div className="meta-item">
|
<div className="flex items-center justify-between py-2 border-b border-line">
|
||||||
<span className="meta-label">Status</span>
|
<span className="text-sm text-muted">Status</span>
|
||||||
<span className={`meta-value status-badge status-${job.status}`}>{job.status}</span>
|
<StatusBadge status={job.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="meta-item">
|
<div className="flex items-center justify-between py-2">
|
||||||
<span className="meta-label">Library</span>
|
<span className="text-sm text-muted">Library</span>
|
||||||
<span className="meta-value">{job.library_id || "All libraries"}</span>
|
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Timeline Card */}
|
{/* Timeline Card */}
|
||||||
<div className="card job-timeline">
|
<Card>
|
||||||
<h2>Timeline</h2>
|
<CardHeader title="Timeline" />
|
||||||
<div className="timeline">
|
<div className="space-y-4">
|
||||||
<div className={`timeline-item ${job.created_at ? 'completed' : ''}`}>
|
<div className="flex items-start gap-4">
|
||||||
<div className="timeline-dot" />
|
<div className={`w-2 h-2 rounded-full mt-2 ${job.created_at ? 'bg-success' : 'bg-muted'}`} />
|
||||||
<div className="timeline-content">
|
<div className="flex-1">
|
||||||
<span className="timeline-label">Created</span>
|
<span className="text-sm font-medium text-foreground">Created</span>
|
||||||
<span className="timeline-time">{new Date(job.created_at).toLocaleString()}</span>
|
<p className="text-sm text-muted">{new Date(job.created_at).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`timeline-item ${job.started_at ? 'completed' : ''} ${!job.started_at ? 'pending' : ''}`}>
|
<div className="flex items-start gap-4">
|
||||||
<div className="timeline-dot" />
|
<div className={`w-2 h-2 rounded-full mt-2 ${job.started_at ? 'bg-success' : job.created_at ? 'bg-warning' : 'bg-muted'}`} />
|
||||||
<div className="timeline-content">
|
<div className="flex-1">
|
||||||
<span className="timeline-label">Started</span>
|
<span className="text-sm font-medium text-foreground">Started</span>
|
||||||
<span className="timeline-time">
|
<p className="text-sm text-muted">
|
||||||
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
|
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`timeline-item ${job.finished_at ? 'completed' : ''} ${job.started_at && !job.finished_at ? 'active' : ''} ${!job.started_at ? 'pending' : ''}`}>
|
<div className="flex items-start gap-4">
|
||||||
<div className="timeline-dot" />
|
<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="timeline-content">
|
<div className="flex-1">
|
||||||
<span className="timeline-label">Finished</span>
|
<span className="text-sm font-medium text-foreground">Finished</span>
|
||||||
<span className="timeline-time">
|
<p className="text-sm text-muted">
|
||||||
{job.finished_at
|
{job.finished_at
|
||||||
? new Date(job.finished_at).toLocaleString()
|
? new Date(job.finished_at).toLocaleString()
|
||||||
: job.started_at
|
: job.started_at
|
||||||
? "Running..."
|
? "Running..."
|
||||||
: "Waiting..."
|
: "Waiting..."
|
||||||
}
|
}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{job.started_at && (
|
{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)}
|
Duration: {formatDuration(job.started_at, job.finished_at)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Progress Card */}
|
{/* Progress Card */}
|
||||||
{(job.status === "running" || job.status === "success" || job.status === "failed") && (
|
{(job.status === "running" || job.status === "success" || job.status === "failed") && (
|
||||||
<div className="card job-progress-detail">
|
<Card>
|
||||||
<h2>Progress</h2>
|
<CardHeader title="Progress" />
|
||||||
{job.total_files && job.total_files > 0 && (
|
{job.total_files && job.total_files > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="progress-bar-large">
|
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
|
||||||
<div
|
<div className="grid grid-cols-3 gap-4">
|
||||||
className="progress-fill"
|
<StatBox value={job.processed_files || 0} label="Processed" variant="primary" />
|
||||||
style={{ width: `${job.progress_percent || 0}%` }}
|
<StatBox value={job.total_files} label="Total" />
|
||||||
/>
|
<StatBox value={job.total_files - (job.processed_files || 0)} label="Remaining" variant="warning" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{job.current_file && (
|
{job.current_file && (
|
||||||
<div className="current-file-box">
|
<div className="mt-4 p-3 bg-muted/5 rounded-lg">
|
||||||
<span className="label">Current file:</span>
|
<span className="text-sm text-muted">Current file:</span>
|
||||||
<code className="file-path">{job.current_file}</code>
|
<code className="block mt-1 text-xs font-mono text-foreground truncate">{job.current_file}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Statistics Card */}
|
{/* Statistics Card */}
|
||||||
{job.stats_json && (
|
{job.stats_json && (
|
||||||
<div className="card job-statistics">
|
<Card>
|
||||||
<h2>Statistics</h2>
|
<CardHeader title="Statistics" />
|
||||||
<div className="stats-grid">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
|
||||||
<div className="stat-item">
|
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
|
||||||
<span className="stat-number success">{job.stats_json.scanned_files}</span>
|
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
|
||||||
<span className="stat-label">Scanned</span>
|
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
|
||||||
</div>
|
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{job.started_at && (
|
{job.started_at && (
|
||||||
<div className="speed-stat">
|
<div className="flex items-center justify-between py-2 border-t border-line">
|
||||||
<span className="speed-label">Speed:</span>
|
<span className="text-sm text-muted">Speed:</span>
|
||||||
<span className="speed-value">{formatSpeed(job.stats_json, duration)}</span>
|
<span className="text-sm font-medium text-foreground">{formatSpeed(job.stats_json, duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Errors Card */}
|
{/* Errors Card */}
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<div className="card job-errors">
|
<Card className="lg:col-span-2">
|
||||||
<h2>Errors ({errors.length})</h2>
|
<CardHeader title={`Errors (${errors.length})`} />
|
||||||
<div className="errors-list">
|
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||||
{errors.map((error) => (
|
{errors.map((error) => (
|
||||||
<div key={error.id} className="error-item">
|
<div key={error.id} className="p-3 bg-error-soft rounded-lg">
|
||||||
<code className="error-file">{error.file_path}</code>
|
<code className="block text-sm font-mono text-error mb-1">{error.file_path}</code>
|
||||||
<span className="error-message">{error.error_message}</span>
|
<p className="text-sm text-error/80">{error.error_message}</p>
|
||||||
<span className="error-time">{new Date(error.created_at).toLocaleString()}</span>
|
<span className="text-xs text-muted">{new Date(error.created_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{job.error_opt && (
|
{job.error_opt && (
|
||||||
<div className="card job-error-message">
|
<Card className="lg:col-span-2">
|
||||||
<h2>Error</h2>
|
<CardHeader title="Error" />
|
||||||
<pre className="error-details">{job.error_opt}</pre>
|
<pre className="p-4 bg-error-soft rounded-lg text-sm text-error overflow-x-auto">{job.error_opt}</pre>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listJobs, fetchLibraries, rebuildIndex, IndexJobDto, LibraryDto } from "../../lib/api";
|
import { listJobs, fetchLibraries, rebuildIndex, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||||
import { JobsList } from "../components/JobsList";
|
import { JobsList } from "../components/JobsList";
|
||||||
|
import { Card, CardHeader, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -30,36 +31,47 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
redirect(`/jobs?highlight=${result.id}`);
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
|
||||||
const apiToken = process.env.API_BOOTSTRAP_TOKEN || "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>Index Jobs</h1>
|
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
|
||||||
<div className="card">
|
<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}>
|
<form action={triggerRebuild}>
|
||||||
<select name="library_id" defaultValue="">
|
<FormRow>
|
||||||
<option value="">All libraries</option>
|
<FormField>
|
||||||
{libraries.map((lib) => (
|
<FormSelect name="library_id" defaultValue="">
|
||||||
<option key={lib.id} value={lib.id}>
|
<option value="">All libraries</option>
|
||||||
{lib.name}
|
{libraries.map((lib) => (
|
||||||
</option>
|
<option key={lib.id} value={lib.id}>
|
||||||
))}
|
{lib.name}
|
||||||
</select>
|
</option>
|
||||||
<button type="submit">Queue Rebuild</button>
|
))}
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit">🔄 Queue Rebuild</Button>
|
||||||
|
</FormRow>
|
||||||
</form>
|
</form>
|
||||||
<form action={triggerFullRebuild} style={{ marginTop: '12px' }}>
|
|
||||||
<select name="library_id" defaultValue="">
|
<form action={triggerFullRebuild} className="mt-3">
|
||||||
<option value="">All libraries</option>
|
<FormRow>
|
||||||
{libraries.map((lib) => (
|
<FormField>
|
||||||
<option key={lib.id} value={lib.id}>
|
<FormSelect name="library_id" defaultValue="">
|
||||||
{lib.name}
|
<option value="">All libraries</option>
|
||||||
</option>
|
{libraries.map((lib) => (
|
||||||
))}
|
<option key={lib.id} value={lib.id}>
|
||||||
</select>
|
{lib.name}
|
||||||
<button type="submit" className="full-rebuild-btn">Full Rebuild (Reindex All)</button>
|
</option>
|
||||||
|
))}
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit" variant="warning">🔁 Full Rebuild</Button>
|
||||||
|
</FormRow>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<JobsList
|
<JobsList
|
||||||
initialJobs={jobs}
|
initialJobs={jobs}
|
||||||
libraries={libraryMap}
|
libraries={libraryMap}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import "./globals.css";
|
|||||||
import { ThemeProvider } from "./theme-provider";
|
import { ThemeProvider } from "./theme-provider";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
import { JobsIndicator } from "./components/JobsIndicator";
|
import { JobsIndicator } from "./components/JobsIndicator";
|
||||||
|
import { NavIcon } from "./components/ui";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Stripstream Backoffice",
|
title: "Stripstream Backoffice",
|
||||||
@@ -38,11 +39,21 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="hidden md:flex items-center gap-1">
|
<div className="hidden md:flex items-center gap-1">
|
||||||
<NavLink href="/">Dashboard</NavLink>
|
<NavLink href="/">
|
||||||
<NavLink href="/books">Books</NavLink>
|
<NavIcon name="dashboard" /> Dashboard
|
||||||
<NavLink href="/libraries">Libraries</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/jobs">Jobs</NavLink>
|
<NavLink href="/books">
|
||||||
<NavLink href="/tokens">Tokens</NavLink>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pl-6 border-l border-line">
|
<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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
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}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,11 +1,79 @@
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="max-w-4xl mx-auto">
|
||||||
<h1>Stripstream Backoffice</h1>
|
<div className="text-center mb-12">
|
||||||
<p>Manage libraries, indexing jobs, and API tokens from a Next.js admin interface.</p>
|
<h1 className="text-4xl font-bold tracking-tight mb-4">
|
||||||
<div className="card">
|
Stripstream Backoffice
|
||||||
<p>Use the navigation links above to access each admin section.</p>
|
</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>
|
||||||
</>
|
|
||||||
|
<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 { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listTokens, createToken, revokeToken, TokenDto } from "../../lib/api";
|
import { listTokens, createToken, revokeToken, TokenDto } from "../../lib/api";
|
||||||
|
import { Card, CardHeader, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -32,57 +33,78 @@ export default async function TokensPage({
|
|||||||
|
|
||||||
return (
|
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 ? (
|
{params.created ? (
|
||||||
<div className="card">
|
<Card className="mb-6">
|
||||||
<strong>Token created (copy it now, it won't be shown again):</strong>
|
<strong className="text-foreground block mb-2">Token created (copy it now, it won't be shown again):</strong>
|
||||||
<pre>{params.created}</pre>
|
<pre className="p-4 bg-muted/10 rounded-lg text-sm font-mono text-foreground overflow-x-auto">{params.created}</pre>
|
||||||
</div>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="card">
|
<Card className="mb-6">
|
||||||
<form action={createTokenAction}>
|
<form action={createTokenAction}>
|
||||||
<input name="name" placeholder="token name" required />
|
<FormRow>
|
||||||
<select name="scope" defaultValue="read">
|
<FormField>
|
||||||
<option value="read">read</option>
|
<FormInput name="name" placeholder="token name" required />
|
||||||
<option value="admin">admin</option>
|
</FormField>
|
||||||
</select>
|
<FormField>
|
||||||
<button type="submit">Create Token</button>
|
<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>
|
</form>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<table>
|
<Card className="overflow-hidden">
|
||||||
<thead>
|
<div className="overflow-x-auto">
|
||||||
<tr>
|
<table className="w-full">
|
||||||
<th>Name</th>
|
<thead>
|
||||||
<th>Scope</th>
|
<tr className="border-b border-line bg-muted/5">
|
||||||
<th>Prefix</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Name</th>
|
||||||
<th>Revoked</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Scope</th>
|
||||||
<th>Actions</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Prefix</th>
|
||||||
</tr>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Revoked</th>
|
||||||
</thead>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
|
||||||
<tbody>
|
</tr>
|
||||||
{tokens.map((token) => (
|
</thead>
|
||||||
<tr key={token.id}>
|
<tbody className="divide-y divide-line">
|
||||||
<td>{token.name}</td>
|
{tokens.map((token) => (
|
||||||
<td>{token.scope}</td>
|
<tr key={token.id} className="hover:bg-muted/5">
|
||||||
<td>
|
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
|
||||||
<code>{token.prefix}</code>
|
<td className="px-4 py-3 text-sm text-foreground">{token.scope}</td>
|
||||||
</td>
|
<td className="px-4 py-3 text-sm">
|
||||||
<td>{token.revoked_at ? "yes" : "no"}</td>
|
<code className="px-2 py-1 bg-muted/10 rounded font-mono text-foreground">{token.prefix}</code>
|
||||||
<td>
|
</td>
|
||||||
{!token.revoked_at && (
|
<td className="px-4 py-3 text-sm">
|
||||||
<form action={revokeTokenAction}>
|
{token.revoked_at ? (
|
||||||
<input type="hidden" name="id" value={token.id} />
|
<span className="inline-flex px-2 py-1 rounded-full text-xs font-semibold bg-error-soft text-error">yes</span>
|
||||||
<button type="submit">Revoke</button>
|
) : (
|
||||||
</form>
|
<span className="inline-flex px-2 py-1 rounded-full text-xs font-semibold bg-success-soft text-success">no</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td className="px-4 py-3">
|
||||||
))}
|
{!token.revoked_at && (
|
||||||
</tbody>
|
<form action={revokeTokenAction}>
|
||||||
</table>
|
<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